subirmansukhani commited on
Commit
f5961bc
·
1 Parent(s): 26962fe

Add model health diagnostics and architecture documentation

Browse files

- Add --model-health mode with weight spectral analysis (WeightWatcher),
attention entropy, and head redundancy checks across all components
- Capture attention from SigLIP vision encoder, VLM+Expert joint
self-attention, and Expert-to-VLM cross-attention via monkey-patched
eager_attention_forward()
- Generate terminal report, markdown report, and 3-panel PNG plot
- Add per-component grouping with vertical separators in plots
- Add descriptive text to each report section explaining the metrics
- Add architecture diagrams (assets/architecture.md) covering data flow,
attention components, execution timeline, and trainable vs frozen parts
- Update README with health diagnostics docs, new CLI flags, and
architecture diagram link

README.md CHANGED
@@ -9,7 +9,14 @@ 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 **visualizes where the model looks** by extracting attention maps from two places:
 
 
 
 
 
 
 
13
 
14
  1. **SigLIP vision encoder** (self-attention) -- which image patches the encoder considers important during feature extraction
15
  2. **Action expert** (cross-attention) -- which image regions the action decoder actually reads when predicting actions
@@ -19,6 +26,18 @@ That lets you check whether the model attends to task-relevant regions (gripper,
19
  **Input:** A pretrained or fine-tuned SmolVLA policy + a LeRobot dataset (e.g. episodes of pick-and-place).
20
  **Output:** A multi-row grid PNG per episode, optional per-frame PNGs, and an optional per-head attention grid.
21
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  ---
23
 
24
  ## How it works
@@ -34,10 +53,10 @@ That lets you check whether the model attends to task-relevant regions (gripper,
34
 
35
  3. **Aggregate across layers** (`--method`):
36
  - `last-layer` -- uses only the final encoder layer
37
- - `rollout` (default) -- multiplies attention across all layers with residual connections, giving a more complete picture of information flow
38
  - `all-layers` -- keeps each layer separately
39
 
40
- 4. **Capture cross-attention** (`--cross-attention`, on by default) -- SmolVLA's VLM builds a KV cache from the prefix (vision + language + state tokens). The action expert queries that cache. The script monkey-patches `eager_attention_forward()` on the expert layers to intercept the softmax attention when expert Q attends to prefix K. Only columns corresponding to vision tokens are kept, giving a heatmap of which image regions the action decoder reads.
41
 
42
  5. **Turn attention into spatial heatmaps** -- patch-level importance scores are reshaped into a 2D grid, upsampled with bilinear interpolation to image size, and normalized to [0, 1].
43
 
@@ -99,7 +118,7 @@ python inspect_attention.py
99
  ### Examples
100
 
101
  ```bash
102
- # Default config: rollout method, cross-attention enabled, per-head grid enabled
103
  ./run.sh
104
 
105
  # Your fine-tuned model
@@ -108,15 +127,27 @@ python inspect_attention.py
108
  # More frames, specific episode
109
  ./run.sh --episode 3 --num-frames 12
110
 
111
- # Last-layer only (faster, no rollout)
112
- ./run.sh --method last-layer
 
 
 
113
 
114
- # Skip cross-attention (faster, 3-row grid only) edit configs/defaults.yaml:
115
- # cross_attention: false
116
 
117
  # Raw attention without positional baseline subtraction
118
  ./run.sh --raw-attention
119
 
 
 
 
 
 
 
 
 
 
120
  # Explicit device override (auto-detected by default: mps > cuda > cpu)
121
  ./run.sh --device cuda
122
  ```
@@ -125,6 +156,8 @@ Results land in `outputs/`.
125
 
126
  ### CLI flags
127
 
 
 
128
  | Flag | Default | Description |
129
  |------|---------|-------------|
130
  | `--model` | `lerobot/smolvla_base` | HuggingFace model ID or local path |
@@ -134,12 +167,24 @@ Results land in `outputs/`.
134
  | `--image-key` | auto-detected | Dataset image key override |
135
  | `--output-dir` | `./outputs` | Output directory |
136
  | `--device` | `auto` | `auto`, `cpu`, `cuda`, or `mps` |
137
- | `--save-individual` | `true` | Save each frame as a separate PNG |
138
- | `--method` | `rollout` | `last-layer`, `rollout`, or `all-layers` |
139
- | `--cross-attention` | `true` | Capture action-expert cross-attention |
140
- | `--show-heads` | `true` | Save per-head attention grid for first frame |
141
  | `--raw-attention` | `false` | Skip positional baseline subtraction |
142
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  Defaults can be changed in `configs/defaults.yaml`.
144
 
145
  ---
@@ -172,14 +217,33 @@ The cyan overlay highlights regions where **both** the vision encoder and the ac
172
 
173
  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.
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  ---
176
 
177
  ## Project layout
178
 
179
  ```
180
  smolvla-inspect/
181
- ├── inspect_attention.py # All logic: model loading, hooks, heatmaps, visualization
182
  ├── assets/
 
183
  │ ├── how_it_works_architecture.png
184
  │ ├── example_grid.png
185
  │ └── example_per_head.png
@@ -188,7 +252,9 @@ smolvla-inspect/
188
  ├── docs/
189
  │ ├── ELI5.md # Plain-language explanation of the interpretability approach
190
  │ └── TESTING.md # CLI test commands and expected output
191
- ├── outputs/ # Generated images (gitignored)
 
 
192
  ├── run.sh # Wrapper that sets FFmpeg lib path
193
  ├── requirements.txt
194
  └── README.md
 
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
+
19
+ Extracts attention maps from two places:
20
 
21
  1. **SigLIP vision encoder** (self-attention) -- which image patches the encoder considers important during feature extraction
22
  2. **Action expert** (cross-attention) -- which image regions the action decoder actually reads when predicting actions
 
26
  **Input:** A pretrained or fine-tuned SmolVLA policy + a LeRobot dataset (e.g. episodes of pick-and-place).
27
  **Output:** A multi-row grid PNG per episode, optional per-frame PNGs, and an optional per-head attention grid.
28
 
29
+ ### Model health diagnostics
30
+
31
+ Runs three diagnostic checks across all model components (SigLIP vision encoder, VLM text model, action expert, connector, and projection heads):
32
+
33
+ 1. **Weight spectral analysis** -- fits a power-law to singular values of each weight matrix using WeightWatcher. The alpha exponent indicates training quality (2-4 = healthy, >6 = severely undertrained).
34
+ 2. **Attention entropy** -- measures how focused or diffuse each attention head is across three attention operations: SigLIP self-attention, VLM+Expert joint self-attention, and Expert-to-VLM cross-attention.
35
+ 3. **Head redundancy** -- measures pairwise cosine similarity between attention heads within each layer. High similarity means wasted capacity.
36
+
37
+ **Output:** Terminal report, markdown report (`model_health_report.md`), and a 3-panel plot (`model_health_report.png`).
38
+
39
+ For a detailed visual walkthrough of the architecture and how it maps to the report, see **[Architecture Diagrams](assets/architecture.md)**.
40
+
41
  ---
42
 
43
  ## How it works
 
53
 
54
  3. **Aggregate across layers** (`--method`):
55
  - `last-layer` -- uses only the final encoder layer
56
+ - `rollout` -- multiplies attention across all layers with residual connections, giving a more complete picture of information flow
57
  - `all-layers` -- keeps each layer separately
58
 
59
+ 4. **Capture cross-attention** (`--cross-attention`) -- SmolVLA's VLM builds a KV cache from the prefix (vision + language + state tokens). The action expert queries that cache. The script monkey-patches `eager_attention_forward()` on the expert layers to intercept the softmax attention when expert Q attends to prefix K. Only columns corresponding to vision tokens are kept, giving a heatmap of which image regions the action decoder reads.
60
 
61
  5. **Turn attention into spatial heatmaps** -- patch-level importance scores are reshaped into a 2D grid, upsampled with bilinear interpolation to image size, and normalized to [0, 1].
62
 
 
118
  ### Examples
119
 
120
  ```bash
121
+ # Default: attention heatmaps with last-layer method
122
  ./run.sh
123
 
124
  # Your fine-tuned model
 
127
  # More frames, specific episode
128
  ./run.sh --episode 3 --num-frames 12
129
 
130
+ # Rollout aggregation (multiply attention across all layers)
131
+ ./run.sh --method rollout
132
+
133
+ # Enable cross-attention capture (slower, adds rows 4-5)
134
+ ./run.sh --cross-attention
135
 
136
+ # Per-head attention grid for the first frame
137
+ ./run.sh --show-heads
138
 
139
  # Raw attention without positional baseline subtraction
140
  ./run.sh --raw-attention
141
 
142
+ # Model health diagnostics (spectral analysis + entropy + redundancy)
143
+ ./run.sh --model-health
144
+
145
+ # Health check with more sample frames for stable entropy estimates
146
+ ./run.sh --model-health --health-frames 10
147
+
148
+ # Custom thresholds for health warnings
149
+ ./run.sh --model-health --entropy-warn 0.85 --redundancy-warn 0.75
150
+
151
  # Explicit device override (auto-detected by default: mps > cuda > cpu)
152
  ./run.sh --device cuda
153
  ```
 
156
 
157
  ### CLI flags
158
 
159
+ **Attention visualization:**
160
+
161
  | Flag | Default | Description |
162
  |------|---------|-------------|
163
  | `--model` | `lerobot/smolvla_base` | HuggingFace model ID or local path |
 
167
  | `--image-key` | auto-detected | Dataset image key override |
168
  | `--output-dir` | `./outputs` | Output directory |
169
  | `--device` | `auto` | `auto`, `cpu`, `cuda`, or `mps` |
170
+ | `--save-individual` | `false` | Save each frame as a separate PNG |
171
+ | `--method` | `last-layer` | `last-layer`, `rollout`, or `all-layers` |
172
+ | `--cross-attention` | `false` | Capture action-expert cross-attention |
173
+ | `--show-heads` | `false` | Save per-head attention grid for first frame |
174
  | `--raw-attention` | `false` | Skip positional baseline subtraction |
175
 
176
+ **Model health diagnostics:**
177
+
178
+ | Flag | Default | Description |
179
+ |------|---------|-------------|
180
+ | `--model-health` | `false` | Run health diagnostics instead of attention heatmaps |
181
+ | `--health-frames` | `5` | Number of sample frames for entropy/redundancy |
182
+ | `--entropy-warn` | `0.8` | Entropy ratio threshold for "unfocused" warning |
183
+ | `--entropy-critical` | `0.95` | Entropy ratio threshold for "dead" heads |
184
+ | `--entropy-low` | `0.1` | Entropy ratio threshold for "collapsed" heads |
185
+ | `--redundancy-warn` | `0.7` | Cosine similarity threshold for "high redundancy" |
186
+ | `--redundancy-critical` | `0.9` | Cosine similarity threshold for "collapsed" heads |
187
+
188
  Defaults can be changed in `configs/defaults.yaml`.
189
 
190
  ---
 
217
 
218
  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.
219
 
220
+ ### Model health report
221
+
222
+ | Metric | Healthy | Warning | Critical |
223
+ |--------|---------|---------|----------|
224
+ | Spectral alpha | 2-4 | 4-6 (undertrained) | >6 (severely undertrained) or <2 (overcorrelated) |
225
+ | Attention entropy | 0.10-0.80 | >0.80 (unfocused) | >0.95 (dead) or <0.10 (collapsed) |
226
+ | Head redundancy | <0.70 (diverse) | >0.70 (redundant) | >0.90 (collapsed) |
227
+
228
+ The report covers three attention components mapped to distinct operations in the architecture:
229
+
230
+ | Report component | Architecture operation | When it runs |
231
+ |-----------------|----------------------|-------------|
232
+ | SigLIP Vision (12L, 12H) | Self-attention inside the vision encoder | Image encoding |
233
+ | VLM+Expert Joint Self-Attn (16L, 15H) | VLM and Expert tokens concatenated, attend to each other | Prefill (initial encoding) |
234
+ | Expert-to-VLM Cross-Attn (16L, 8H) | Expert queries VLM's cached keys/values | Generation (action decoding, 10 steps) |
235
+
236
+ See **[Architecture Diagrams](assets/architecture.md)** for visual explanations of each component.
237
+
238
  ---
239
 
240
  ## Project layout
241
 
242
  ```
243
  smolvla-inspect/
244
+ ├── inspect_attention.py # All logic: model loading, hooks, heatmaps, health diagnostics
245
  ├── assets/
246
+ │ ├── architecture.md # Architecture diagrams and report reference
247
  │ ├── how_it_works_architecture.png
248
  │ ├── example_grid.png
249
  │ └── example_per_head.png
 
252
  ├── docs/
253
  │ ├── ELI5.md # Plain-language explanation of the interpretability approach
254
  │ └── TESTING.md # CLI test commands and expected output
255
+ ├── outputs/ # Generated images and reports (gitignored)
256
+ │ ├── model_health_report.md
257
+ │ └── model_health_report.png
258
  ├── run.sh # Wrapper that sets FFmpeg lib path
259
  ├── requirements.txt
260
  └── README.md
assets/architecture.md ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SmolVLA Architecture Diagrams
2
+
3
+ Reference diagrams for understanding the SmolVLA model structure and what
4
+ the `--model-health` report measures.
5
+
6
+ ---
7
+
8
+ ## 1. High-level data flow
9
+
10
+ ```
11
+ Camera Images (512x512) Task String Robot State Noisy Actions
12
+ +--------+ +--------+ "pick up the (joint (from flow
13
+ | side | | up | red block" positions) matching)
14
+ +---+----+ +---+----+ | | |
15
+ | | | | |
16
+ v v v v v
17
+ +------------------+ +---------------+ +---------------+ +---------------+
18
+ | SigLIP Vision | | Tokenizer | | state_proj | | action_in_proj|
19
+ | Encoder | | | | (Linear) | | (Linear) |
20
+ | (frozen) | | | | | | + time MLP |
21
+ | 12L, 12H | | | | trainable | | trainable |
22
+ +--------+---------+ +-------+-------+ +-------+-------+ +-------+-------+
23
+ | | | |
24
+ 1024 patches token embeds 1 token 10 tokens
25
+ | | | |
26
+ v | | |
27
+ +-------------------+ | | |
28
+ | Connector | | | |
29
+ | (pixel shuffle) | | | |
30
+ | trainable | | | |
31
+ +--------+----------+ | | |
32
+ | | | |
33
+ 64 tokens | | |
34
+ | | | |
35
+ +-----------+-----------+ | |
36
+ | | |
37
+ v | |
38
+ +-----------------------+ | |
39
+ | PREFIX SEQUENCE | | |
40
+ | [vision] [language] +<--------------------+ |
41
+ | [state] | VLM dim (960) |
42
+ +-----------+-----------+ |
43
+ | |
44
+ | +-----------------+
45
+ | |
46
+ v v
47
+ +---------------------------------------------------+
48
+ | SmolVLMWithExpert (16 layers) |
49
+ | |
50
+ | PREFIX -------------------> VLM Text Model |
51
+ | (vision+lang+state) (frozen) |
52
+ | 960-dim, 15 heads |
53
+ | |
54
+ | SUFFIX -------------------> Action Expert |
55
+ | (noisy actions+time) (trainable) |
56
+ | 480-dim, 8 heads |
57
+ | |
58
+ | (see attention diagrams below) |
59
+ +--------------------------+--------------------------|
60
+ |
61
+ | Expert output (last 10 tokens)
62
+ v
63
+ +-------------------+
64
+ | action_out_proj |
65
+ | (Linear) |
66
+ | trainable |
67
+ +---------+---------+
68
+ |
69
+ v
70
+ Predicted Actions
71
+ (10 steps x 6 DOF)
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 2. The three attention components
77
+
78
+ These are the three distinct attention operations that the health report
79
+ measures for entropy and head redundancy.
80
+
81
+ ### A. SigLIP Vision (12 layers, 12 heads)
82
+
83
+ Standard self-attention inside the vision encoder. Happens before anything
84
+ enters SmolVLMWithExpert. Image patches attend to other image patches.
85
+
86
+ ```
87
+ patch_1 patch_2 patch_3 ... patch_1024
88
+ | | | |
89
+ v v v v
90
+ +----------------------------------------------+
91
+ | SigLIP Self-Attention |
92
+ | |
93
+ | Each patch can see all patches. |
94
+ | |
95
+ | Q <-- patches K <-- patches |
96
+ | (1024 x 1024 attention matrix) |
97
+ +----------------------------------------------+
98
+
99
+ Report label: "SigLIP Vision (12L, 12H)"
100
+ ```
101
+
102
+ ### B. VLM+Expert Joint Self-Attention (16 layers, 15 heads)
103
+
104
+ Runs during prefill (initial encoding). VLM and Expert tokens are
105
+ concatenated into one sequence and attend to each other in a single
106
+ attention call.
107
+
108
+ ```
109
+ VLM tokens (prefix) Expert tokens (suffix)
110
+ [vision][lang][state] [action+time]
111
+ | |
112
+ v v
113
+ +-------------+ +-------------+
114
+ | VLM layer | | Expert layer|
115
+ | q/k/v proj | | q/k/v proj |
116
+ +------+------+ +------+------+
117
+ | |
118
+ +-------------+---------------+
119
+ |
120
+ v
121
+ torch.cat(dim=seq)
122
+ |
123
+ v
124
+ +----------------------------------------------+
125
+ | JOINT Self-Attention |
126
+ | |
127
+ | All tokens see all tokens: |
128
+ | vision <--> vision |
129
+ | vision <--> language |
130
+ | vision <--> action |
131
+ | action <--> language |
132
+ | action <--> action |
133
+ | ... etc |
134
+ | |
135
+ | Q <-- [VLM+Expert] K <-- [VLM+Expert] |
136
+ | Q_len == K_len (self-attention) |
137
+ +----------------------------------------------+
138
+
139
+ Report label: "VLM+Expert Joint Self-Attn (16L, 15H)"
140
+ ```
141
+
142
+ ### C. Expert-to-VLM Cross-Attention (16 layers, 8 heads)
143
+
144
+ Runs during generation (autoregressive action decoding). The Expert
145
+ generates queries from its own hidden states, but keys and values come
146
+ from the VLM's cached representations, re-projected through the Expert's
147
+ k_proj and v_proj. Repeats 10 times (one per action token).
148
+
149
+ ```
150
+ VLM KV Cache Expert hidden states
151
+ (built during prefill) (current action token)
152
+ | |
153
+ | v
154
+ | +---------------+
155
+ | | Expert q_proj |
156
+ | +-------+-------+
157
+ | |
158
+ | re-projected through |
159
+ | Expert k_proj / v_proj |
160
+ v |
161
+ +-------------+ |
162
+ | Expert K, V |<-- VLM cache |
163
+ | (from VLM) | re-projected |
164
+ +------+------+ |
165
+ | |
166
+ +---------------+-------------------+
167
+ |
168
+ v
169
+ +----------------------------------------------+
170
+ | CROSS-Attention |
171
+ | |
172
+ | Expert reads VLM: |
173
+ | action --> vision (what to grab?) |
174
+ | action --> language (what was asked?) |
175
+ | action --> state (current pose?) |
176
+ | |
177
+ | VLM does NOT read Expert here. |
178
+ | |
179
+ | Q <-- Expert tokens (few) |
180
+ | K <-- VLM prefix (many) |
181
+ | Q_len != K_len (cross-attention) |
182
+ +----------------------------------------------+
183
+
184
+ Report label: "Expert-to-VLM Cross-Attn (16L, 8H)"
185
+ ```
186
+
187
+ ---
188
+
189
+ ## 3. Execution timeline
190
+
191
+ ```
192
+ IMAGE ENCODING PREFILL GENERATION
193
+ +--------------+ +------------------+ +------+------+ +------+
194
+ | SigLIP | | Joint Self-Attn | | XA | XA | | XA |
195
+ | 12 layers +--->| 16 layers +--->| #1 | #2 |...>| #10 |
196
+ | (A) | | (B) | | | | | |
197
+ +--------------+ +------------------+ +------+------+ +------+
198
+ <--- 10 action steps --->
199
+
200
+ Report: Report: Report:
201
+ "SigLIP Vision" "VLM+Expert "Expert-to-VLM
202
+ Joint Self-Attn" Cross-Attn"
203
+ 12L x 12H 16L x 15H 16L x 8H
204
+ (x10 steps, averaged)
205
+ ```
206
+
207
+ ---
208
+
209
+ ## 4. What the health report measures
210
+
211
+ ### Section 1: Weight Spectral Analysis (alpha)
212
+
213
+ Analyzes weight matrices directly (no data needed). Each transformer layer
214
+ contains multiple weight matrices (q_proj, k_proj, v_proj, o_proj,
215
+ gate_proj, up_proj, down_proj). A power-law is fit to each matrix's
216
+ singular values; the exponent (alpha) indicates training quality.
217
+
218
+ ```
219
+ +----------------------------------+-------------+---------------------+
220
+ | Component | Wt Matrices | What it is |
221
+ +----------------------------------+-------------+---------------------+
222
+ | Expert (trainable) | 112 = 16x7 | Action decoder |
223
+ | VLM Text Model (frozen) | 113 = 16x7+ | Language model |
224
+ | Vision Encoder (frozen) | 74 = 12x~6 | SigLIP |
225
+ | Connector (trainable) | 1 | Pixel shuffle |
226
+ | Projections (trainable) | too small | state/action linear |
227
+ +----------------------------------+-------------+---------------------+
228
+
229
+ Healthy: alpha 2-4 Undertrained: 4-6 Severe: >6
230
+ ```
231
+
232
+ ### Section 2: Attention Entropy
233
+
234
+ Requires a forward pass with real data. Measures how spread out each
235
+ attention head's focus is (normalized by log of sequence length).
236
+
237
+ ```
238
+ Collapsed: <0.10 Healthy: 0.10-0.80 Unfocused: >0.80 Dead: >0.95
239
+ ```
240
+
241
+ ### Section 3: Head Redundancy
242
+
243
+ Same forward pass data. Measures pairwise cosine similarity between
244
+ flattened head attention patterns within each layer. Heads should learn
245
+ different patterns.
246
+
247
+ ```
248
+ Diverse: <0.70 High redundancy: >0.70 Collapsed: >0.90
249
+ ```
250
+
251
+ ---
252
+
253
+ ## 5. Trainable vs frozen components
254
+
255
+ ```
256
+ FROZEN (pretrained, not updated during fine-tuning)
257
+ +----------------------------------------------------+
258
+ | SigLIP Vision Encoder |
259
+ | 12 layers, 12 heads, 74 weight matrices |
260
+ +----------------------------------------------------+
261
+ | VLM Text Model (SmolLM2) |
262
+ | 16 layers, 15 heads, 113 weight matrices |
263
+ +----------------------------------------------------+
264
+
265
+ TRAINABLE (learned during fine-tuning)
266
+ +----------------------------------------------------+
267
+ | Action Expert |
268
+ | 16 layers, 8 heads, 112 weight matrices |
269
+ +----------------------------------------------------+
270
+ | Connector (pixel shuffle) |
271
+ | 1 weight matrix |
272
+ +----------------------------------------------------+
273
+ | state_proj, action_in_proj, action_out_proj |
274
+ | (too small for spectral analysis) |
275
+ +----------------------------------------------------+
276
+ ```
configs/defaults.yaml CHANGED
@@ -13,3 +13,12 @@ method: rollout # last-layer | rollout | all-layers
13
  cross_attention: true # true to capture action-expert → vision cross-attention (slower)
14
  show_heads: true # true to save a per-head attention grid for the first frame
15
  raw_attention: false # true to skip positional baseline subtraction
 
 
 
 
 
 
 
 
 
 
13
  cross_attention: true # true to capture action-expert → vision cross-attention (slower)
14
  show_heads: true # true to save a per-head attention grid for the first frame
15
  raw_attention: false # true to skip positional baseline subtraction
16
+
17
+ # Model health analysis
18
+ model_health: false
19
+ health_frames: 5
20
+ entropy_warn: 0.8
21
+ entropy_critical: 0.95
22
+ entropy_low: 0.1
23
+ redundancy_warn: 0.7
24
+ redundancy_critical: 0.9
inspect_attention.py CHANGED
@@ -345,6 +345,129 @@ class ActionVisionAttentionCapture:
345
  return accum / count
346
 
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  # ---------------------------------------------------------------------------
349
  # 2. Gradient-based attention fallback (GradCAM-style)
350
  # ---------------------------------------------------------------------------
@@ -1502,7 +1625,859 @@ def gradient_attention_map(policy, dataset, frame_idx, image_key, device="cpu"):
1502
 
1503
 
1504
  # ---------------------------------------------------------------------------
1505
- # 8. Entry point
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1506
  # ---------------------------------------------------------------------------
1507
 
1508
  def load_defaults():
@@ -1562,6 +2537,29 @@ Examples:
1562
  default=defaults.get("raw_attention", False),
1563
  help="Skip positional baseline subtraction (show raw attention)")
1564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1565
  args = parser.parse_args()
1566
 
1567
  os.makedirs(args.output_dir, exist_ok=True)
@@ -1639,7 +2637,12 @@ Examples:
1639
  except Exception as e:
1640
  print(f"\n ERROR loading dataset: {e}")
1641
  sys.exit(1)
1642
-
 
 
 
 
 
1643
  # --- Extract attention maps ---
1644
  print(f"\n[Step 3] Extracting attention maps...")
1645
 
 
345
  return accum / count
346
 
347
 
348
+ # ---------------------------------------------------------------------------
349
+ # 1c. Decoder attention capture — VLM+Expert self-attn & Expert cross-attn
350
+ # ---------------------------------------------------------------------------
351
+
352
+ class DecoderAttentionCapture:
353
+ """
354
+ Captures attention weights from ``SmolVLMWithExpert.eager_attention_forward``.
355
+
356
+ SmolVLA bypasses the standard ``LlamaAttention.forward()`` path — instead,
357
+ ``forward_attn_layer`` / ``forward_cross_attn_layer`` manually project
358
+ Q/K/V and call ``eager_attention_forward`` directly. So we monkey-patch
359
+ that method to intercept the attention probabilities.
360
+
361
+ Captured maps are categorised by the Q/K sequence lengths:
362
+ - **Self-attention** (``q_len == k_len``, ``q_len > 1``): prefill phase
363
+ where VLM + Expert tokens are concatenated.
364
+ - **Cross-attention** (``q_len != k_len``): Expert queries attending to
365
+ the VLM prefix KV cache.
366
+ """
367
+
368
+ def __init__(self):
369
+ self.self_attn_maps = [] # (seq_idx, probs) for prefill self-attn
370
+ self.cross_attn_maps = [] # (seq_idx, probs) for expert cross-attn
371
+ self._original_fn = None
372
+ self._patched_obj = None
373
+ self._self_idx = 0
374
+ self._cross_idx = 0
375
+
376
+ def register(self, vlm_with_expert):
377
+ """Wrap ``eager_attention_forward`` on *vlm_with_expert*."""
378
+ import types
379
+
380
+ self.clear()
381
+ self._original_fn = vlm_with_expert.eager_attention_forward
382
+ self._patched_obj = vlm_with_expert
383
+ capture = self
384
+
385
+ def _wrapped(self_model, attention_mask, batch_size, head_dim,
386
+ query_states, key_states, value_states):
387
+ # Ensure boolean mask
388
+ if attention_mask.dtype != torch.bool:
389
+ attention_mask = attention_mask.bool()
390
+
391
+ # ---------- original computation ----------
392
+ output = capture._original_fn(
393
+ attention_mask, batch_size, head_dim,
394
+ query_states, key_states, value_states,
395
+ )
396
+
397
+ # ---------- capture attention probs ----------
398
+ q_len = query_states.shape[1]
399
+ k_len = key_states.shape[1]
400
+
401
+ # Only capture meaningful attention (skip single-token
402
+ # autoregressive steps which have q_len == 1)
403
+ if q_len > 1:
404
+ num_att_heads = self_model.num_attention_heads
405
+ num_kv_heads = self_model.num_key_value_heads
406
+ num_kv_groups = num_att_heads // num_kv_heads
407
+ seq_len_k = key_states.shape[1]
408
+
409
+ ks = key_states[:, :, :, None, :].expand(
410
+ batch_size, seq_len_k, num_kv_heads, num_kv_groups, head_dim
411
+ ).reshape(batch_size, seq_len_k, num_kv_heads * num_kv_groups, head_dim)
412
+
413
+ q = query_states.to(dtype=torch.float32).transpose(1, 2)
414
+ k = ks.to(dtype=torch.float32).transpose(1, 2)
415
+
416
+ scores = torch.matmul(q, k.transpose(2, 3)) * (head_dim ** -0.5)
417
+ big_neg = torch.finfo(scores.dtype).min
418
+ scores = torch.where(
419
+ attention_mask[:, None, :, :], scores, big_neg
420
+ )
421
+ probs = F.softmax(scores, dim=-1)
422
+
423
+ if q_len == k_len:
424
+ capture.self_attn_maps.append(
425
+ (capture._self_idx, probs.detach().cpu())
426
+ )
427
+ capture._self_idx += 1
428
+ else:
429
+ capture.cross_attn_maps.append(
430
+ (capture._cross_idx, probs.detach().cpu())
431
+ )
432
+ capture._cross_idx += 1
433
+
434
+ return output
435
+
436
+ vlm_with_expert.eager_attention_forward = types.MethodType(
437
+ _wrapped, vlm_with_expert
438
+ )
439
+
440
+ def clear(self):
441
+ """Remove the monkey-patch and discard captured data."""
442
+ if self._original_fn is not None and self._patched_obj is not None:
443
+ self._patched_obj.eager_attention_forward = self._original_fn
444
+ self._original_fn = None
445
+ self._patched_obj = None
446
+ self.self_attn_maps = []
447
+ self.cross_attn_maps = []
448
+ self._self_idx = 0
449
+ self._cross_idx = 0
450
+
451
+ def reset_maps(self):
452
+ """Clear captured maps but keep the monkey-patch active."""
453
+ self.self_attn_maps = []
454
+ self.cross_attn_maps = []
455
+ self._self_idx = 0
456
+ self._cross_idx = 0
457
+
458
+ def get_self_attn_layers(self):
459
+ """Return prefill self-attention maps sorted by sequential index."""
460
+ if not self.self_attn_maps:
461
+ return []
462
+ return sorted(self.self_attn_maps, key=lambda x: x[0])
463
+
464
+ def get_cross_attn_layers(self):
465
+ """Return expert cross-attention maps sorted by sequential index."""
466
+ if not self.cross_attn_maps:
467
+ return []
468
+ return sorted(self.cross_attn_maps, key=lambda x: x[0])
469
+
470
+
471
  # ---------------------------------------------------------------------------
472
  # 2. Gradient-based attention fallback (GradCAM-style)
473
  # ---------------------------------------------------------------------------
 
1625
 
1626
 
1627
  # ---------------------------------------------------------------------------
1628
+ # 8. Model health diagnostics
1629
+ # ---------------------------------------------------------------------------
1630
+
1631
+ def compute_weightwatcher_alpha(policy):
1632
+ """
1633
+ Run WeightWatcher spectral analysis on each trainable (and frozen
1634
+ reference) component of SmolVLA.
1635
+
1636
+ Returns dict keyed by component name, values are lists of
1637
+ ``{layer, alpha}`` dicts. Components that are too small for
1638
+ reliable SVD are reported with ``alpha=None``.
1639
+ """
1640
+ try:
1641
+ import weightwatcher as ww
1642
+ except ImportError:
1643
+ print(" ERROR: weightwatcher not installed. Run: pip install weightwatcher")
1644
+ print(" Skipping spectral alpha analysis.")
1645
+ return None
1646
+
1647
+ components = {}
1648
+
1649
+ # --- helper: run ww on a submodel, return list of {layer, alpha} ---
1650
+ def _analyze(name, submodel):
1651
+ try:
1652
+ watcher = ww.WeightWatcher(model=submodel)
1653
+ details = watcher.analyze(min_evals=50)
1654
+ results = []
1655
+ for idx, row in details.iterrows():
1656
+ results.append({"layer": idx, "alpha": row.get("alpha", None)})
1657
+ components[name] = results
1658
+ except Exception as e:
1659
+ print(f" WARNING: WeightWatcher failed on {name}: {e}")
1660
+ components[name] = []
1661
+
1662
+ vlm_with_expert = policy.model.vlm_with_expert
1663
+
1664
+ # Trainable components
1665
+ print(" Analyzing expert layers...")
1666
+ _analyze("Expert (trainable)", vlm_with_expert.lm_expert)
1667
+
1668
+ print(" Analyzing connector...")
1669
+ _analyze("Connector (trainable)", vlm_with_expert.get_vlm_model().connector)
1670
+
1671
+ # Projection heads
1672
+ proj_names = [n for n, _ in policy.model.named_children()
1673
+ if "proj" in n.lower()]
1674
+ if proj_names:
1675
+ print(f" Analyzing projection heads ({', '.join(proj_names)})...")
1676
+ for pn in proj_names:
1677
+ _analyze(f"Projection/{pn} (trainable)", getattr(policy.model, pn))
1678
+
1679
+ # Frozen reference components
1680
+ print(" Analyzing vision encoder (frozen reference)...")
1681
+ _analyze("Vision Encoder (frozen)", vlm_with_expert.get_vlm_model().vision_model)
1682
+
1683
+ print(" Analyzing VLM text model (frozen reference)...")
1684
+ _analyze("VLM Text Model (frozen)", vlm_with_expert.get_vlm_model().text_model)
1685
+
1686
+ return components
1687
+
1688
+
1689
+ def compute_attention_entropy(attn_maps, num_patches):
1690
+ """
1691
+ Per-head, per-layer attention entropy as a fraction of maximum
1692
+ entropy (``log(num_patches)``).
1693
+
1694
+ Args:
1695
+ attn_maps: list of ``(layer_idx, attn_weights)`` tuples.
1696
+ Each ``attn_weights`` has shape ``(batch, heads, patches, patches)``
1697
+ or ``(heads, patches, patches)``.
1698
+ num_patches: total number of patches (for max-entropy normalisation).
1699
+
1700
+ Returns:
1701
+ list of ``{layer, head, entropy, entropy_ratio}`` dicts.
1702
+ """
1703
+ max_entropy = math.log(num_patches) if num_patches > 1 else 1.0
1704
+ eps = 1e-8
1705
+ results = []
1706
+
1707
+ for layer_idx, attn in sorted(attn_maps, key=lambda x: x[0]):
1708
+ # Reduce to (heads, patches, patches)
1709
+ while attn.dim() > 3:
1710
+ attn = attn[0]
1711
+ if attn.dim() == 2:
1712
+ attn = attn.unsqueeze(0)
1713
+
1714
+ n_heads = attn.shape[0]
1715
+ for h in range(n_heads):
1716
+ head_attn = attn[h].float() # (patches, patches)
1717
+ # Entropy per row, then average across rows
1718
+ ent = -(head_attn * torch.log(head_attn + eps)).sum(dim=-1).mean().item()
1719
+ results.append({
1720
+ "layer": layer_idx,
1721
+ "head": h,
1722
+ "entropy": ent,
1723
+ "entropy_ratio": ent / max_entropy,
1724
+ })
1725
+
1726
+ return results
1727
+
1728
+
1729
+ def compute_head_redundancy(attn_maps):
1730
+ """
1731
+ Pairwise cosine similarity between flattened head attention patterns
1732
+ within each layer.
1733
+
1734
+ Args:
1735
+ attn_maps: list of ``(layer_idx, attn_weights)`` tuples.
1736
+
1737
+ Returns:
1738
+ list of ``{layer, mean_redundancy, max_redundancy}`` dicts.
1739
+ """
1740
+ results = []
1741
+
1742
+ for layer_idx, attn in sorted(attn_maps, key=lambda x: x[0]):
1743
+ while attn.dim() > 3:
1744
+ attn = attn[0]
1745
+ if attn.dim() == 2:
1746
+ attn = attn.unsqueeze(0)
1747
+
1748
+ n_heads = attn.shape[0]
1749
+ if n_heads < 2:
1750
+ results.append({
1751
+ "layer": layer_idx,
1752
+ "mean_redundancy": 0.0,
1753
+ "max_redundancy": 0.0,
1754
+ })
1755
+ continue
1756
+
1757
+ # Flatten each head's attention to a vector
1758
+ flat = attn.float().reshape(n_heads, -1) # (heads, patches*patches)
1759
+ # Normalise
1760
+ flat_norm = F.normalize(flat, dim=1)
1761
+ # Pairwise cosine similarity
1762
+ sim = torch.mm(flat_norm, flat_norm.t()) # (heads, heads)
1763
+
1764
+ # Extract upper triangle (exclude diagonal)
1765
+ mask = torch.triu(torch.ones(n_heads, n_heads, dtype=torch.bool), diagonal=1)
1766
+ pairwise = sim[mask]
1767
+ results.append({
1768
+ "layer": layer_idx,
1769
+ "mean_redundancy": pairwise.mean().item(),
1770
+ "max_redundancy": pairwise.max().item(),
1771
+ })
1772
+
1773
+ return results
1774
+
1775
+
1776
+ def classify_health(alpha=None, entropy=None, redundancy=None, thresholds=None):
1777
+ """
1778
+ Classify a single metric value into a status string and ANSI color.
1779
+
1780
+ Alpha thresholds (hardcoded, RMT-derived):
1781
+ <2 overcorrelated, 2-4 healthy, 4-6 undertrained, >6 severely undertrained
1782
+
1783
+ Entropy/redundancy thresholds come from *thresholds* dict.
1784
+
1785
+ Returns:
1786
+ ``(ansi_status_str, ansi_color_code, plain_label)``
1787
+ """
1788
+ GREEN = "\033[92m"
1789
+ YELLOW = "\033[93m"
1790
+ RED = "\033[91m"
1791
+ RESET = "\033[0m"
1792
+
1793
+ if thresholds is None:
1794
+ thresholds = {}
1795
+
1796
+ if alpha is not None:
1797
+ if alpha < 2:
1798
+ return f"{RED}overcorrelated{RESET}", RED, "overcorrelated"
1799
+ elif alpha <= 4:
1800
+ return f"{GREEN}healthy{RESET}", GREEN, "healthy"
1801
+ elif alpha <= 6:
1802
+ return f"{YELLOW}undertrained{RESET}", YELLOW, "undertrained"
1803
+ else:
1804
+ return f"{RED}severely undertrained{RESET}", RED, "severely undertrained"
1805
+
1806
+ if entropy is not None:
1807
+ crit = thresholds.get("entropy_critical", 0.95)
1808
+ warn = thresholds.get("entropy_warn", 0.8)
1809
+ low = thresholds.get("entropy_low", 0.1)
1810
+ if entropy >= crit:
1811
+ return f"{RED}uniform/dead{RESET}", RED, "uniform/dead"
1812
+ elif entropy >= warn:
1813
+ return f"{YELLOW}unfocused{RESET}", YELLOW, "unfocused"
1814
+ elif entropy <= low:
1815
+ return f"{RED}collapsed{RESET}", RED, "collapsed"
1816
+ else:
1817
+ return f"{GREEN}healthy{RESET}", GREEN, "healthy"
1818
+
1819
+ if redundancy is not None:
1820
+ r_crit = thresholds.get("redundancy_critical", 0.9)
1821
+ r_warn = thresholds.get("redundancy_warn", 0.7)
1822
+ if redundancy >= r_crit:
1823
+ return f"{RED}collapsed{RESET}", RED, "collapsed"
1824
+ elif redundancy >= r_warn:
1825
+ return f"{YELLOW}high redundancy{RESET}", YELLOW, "high redundancy"
1826
+ else:
1827
+ return f"{GREEN}diverse{RESET}", GREEN, "diverse"
1828
+
1829
+ return "unknown", "", "unknown"
1830
+
1831
+
1832
+ def print_health_report(ww_results, entropy_results, redundancy_results, thresholds):
1833
+ """
1834
+ Print a formatted terminal table with ANSI color-coded status per
1835
+ layer/component.
1836
+ """
1837
+ BOLD = "\033[1m"
1838
+ RESET = "\033[0m"
1839
+ DIM = "\033[2m"
1840
+
1841
+ print(f"\n{'=' * 78}")
1842
+ print(f"{BOLD}MODEL HEALTH REPORT{RESET}")
1843
+ print(f"{'=' * 78}")
1844
+
1845
+ # --- Section 1: Weight Spectral Analysis ---
1846
+ if ww_results is not None:
1847
+ print(f"\n{BOLD}1. Weight Spectral Analysis (alpha){RESET}")
1848
+ print(f" Fits a power-law to each weight matrix's singular values.")
1849
+ print(f" Alpha (α) measures how well-trained a layer is — values of 2-4 indicate")
1850
+ print(f" strong correlation structure learned during training. High alpha means the")
1851
+ print(f" layer hasn't learned enough structure; low alpha means overcorrelation.")
1852
+ print(f" {DIM}Healthy: 2-4 | Undertrained: 4-6 | Overcorrelated: <2 | Severe: >6{RESET}")
1853
+ print(f" {'Component':<35} {'Wt Matrices':>11} {'Mean α':>8} {'Min α':>8} {'Max α':>8} Status")
1854
+ print(f" {'-' * 77}")
1855
+ for comp_name, layers in ww_results.items():
1856
+ alphas = [l["alpha"] for l in layers if l.get("alpha") is not None]
1857
+ if not alphas:
1858
+ print(f" {comp_name:<35} {'—':>11} {'N/A':>8} {'N/A':>8} {'N/A':>8} {DIM}too small{RESET}")
1859
+ continue
1860
+ mean_a = sum(alphas) / len(alphas)
1861
+ min_a = min(alphas)
1862
+ max_a = max(alphas)
1863
+ status, _, _ = classify_health(alpha=mean_a)
1864
+ print(f" {comp_name:<35} {len(layers):>11} {mean_a:>8.2f} {min_a:>8.2f} {max_a:>8.2f} {status}")
1865
+ else:
1866
+ print(f"\n{BOLD}1. Weight Spectral Analysis{RESET}")
1867
+ print(f" {DIM}Skipped (weightwatcher not available){RESET}")
1868
+
1869
+ # --- Section 2: Attention Entropy ---
1870
+ if entropy_results:
1871
+ print(f"\n{BOLD}2. Attention Entropy (fraction of max){RESET}")
1872
+ print(f" Measures how spread out each attention head's focus is.")
1873
+ print(f" Low entropy means the head attends to very few tokens (collapsed/dead).")
1874
+ print(f" High entropy means the head spreads attention nearly uniformly (unfocused).")
1875
+ print(f" Healthy heads are selective but not degenerate — attending to a meaningful subset.")
1876
+ print(f" {DIM}Collapsed: <{thresholds.get('entropy_low', 0.1):.2f} | "
1877
+ f"Healthy: {thresholds.get('entropy_low', 0.1):.2f}-{thresholds.get('entropy_warn', 0.8):.2f} | "
1878
+ f"Unfocused: >{thresholds.get('entropy_warn', 0.8):.2f} | "
1879
+ f"Dead: >{thresholds.get('entropy_critical', 0.95):.2f}{RESET}")
1880
+
1881
+ from collections import defaultdict
1882
+ for comp_name, comp_entries in entropy_results.items():
1883
+ print(f"\n {BOLD}{comp_name}{RESET}")
1884
+ by_layer = defaultdict(list)
1885
+ for e in comp_entries:
1886
+ by_layer[e["layer"]].append(e["entropy_ratio"])
1887
+
1888
+ print(f" {'Layer':>6} {'Mean Ent':>10} {'Min Ent':>10} {'Max Ent':>10} Status")
1889
+ print(f" {'-' * 56}")
1890
+ for layer in sorted(by_layer.keys()):
1891
+ vals = by_layer[layer]
1892
+ mean_e = sum(vals) / len(vals)
1893
+ min_e = min(vals)
1894
+ max_e = max(vals)
1895
+ status, _, _ = classify_health(entropy=mean_e, thresholds=thresholds)
1896
+ print(f" {layer:>6} {mean_e:>10.4f} {min_e:>10.4f} {max_e:>10.4f} {status}")
1897
+ else:
1898
+ print(f"\n{BOLD}2. Attention Entropy{RESET}")
1899
+ print(f" {DIM}No data{RESET}")
1900
+
1901
+ # --- Section 3: Head Redundancy ---
1902
+ if redundancy_results:
1903
+ print(f"\n{BOLD}3. Head Redundancy (cosine similarity){RESET}")
1904
+ print(f" Measures how similar the attention heads are to each other within each layer.")
1905
+ print(f" Each layer has multiple heads that should learn different patterns (e.g., one")
1906
+ print(f" head for spatial relations, another for color). High similarity means heads are")
1907
+ print(f" redundant — wasted capacity. Collapsed means nearly identical heads.")
1908
+ print(f" {DIM}Diverse: <{thresholds.get('redundancy_warn', 0.7):.2f} | "
1909
+ f"High: >{thresholds.get('redundancy_warn', 0.7):.2f} | "
1910
+ f"Collapsed: >{thresholds.get('redundancy_critical', 0.9):.2f}{RESET}")
1911
+
1912
+ for comp_name, comp_entries in redundancy_results.items():
1913
+ print(f"\n {BOLD}{comp_name}{RESET}")
1914
+ print(f" {'Layer':>6} {'Mean Sim':>10} {'Max Sim':>10} Status")
1915
+ print(f" {'-' * 46}")
1916
+ for r in comp_entries:
1917
+ status, _, _ = classify_health(redundancy=r["mean_redundancy"], thresholds=thresholds)
1918
+ print(f" {r['layer']:>6} {r['mean_redundancy']:>10.4f} {r['max_redundancy']:>10.4f} {status}")
1919
+ else:
1920
+ print(f"\n{BOLD}3. Head Redundancy{RESET}")
1921
+ print(f" {DIM}No data{RESET}")
1922
+
1923
+ print(f"\n{'=' * 78}\n")
1924
+
1925
+
1926
+ def _status_emoji(label):
1927
+ """Map a plain health label to a markdown-friendly status indicator."""
1928
+ if label in ("healthy", "diverse"):
1929
+ return "OK"
1930
+ elif label in ("undertrained", "unfocused", "high redundancy"):
1931
+ return "WARN"
1932
+ else:
1933
+ return "CRITICAL"
1934
+
1935
+
1936
+ def generate_health_markdown(ww_results, entropy_results, redundancy_results, thresholds):
1937
+ """
1938
+ Build a Markdown report string from health diagnostics results.
1939
+ """
1940
+ from collections import defaultdict
1941
+ lines = []
1942
+ w = lines.append
1943
+
1944
+ w("# Model Health Report\n")
1945
+
1946
+ # --- Section 1: Weight Spectral Analysis ---
1947
+ w("## 1. Weight Spectral Analysis (alpha)\n")
1948
+ w("Fits a power-law to each weight matrix's singular values. "
1949
+ "Alpha measures how well-trained a layer is — values of 2-4 indicate "
1950
+ "strong correlation structure learned during training. High alpha means the "
1951
+ "layer hasn't learned enough structure; low alpha means overcorrelation.\n")
1952
+ if ww_results is not None:
1953
+ w("> Healthy: 2-4 | Undertrained: 4-6 | Overcorrelated: <2 | Severe: >6\n")
1954
+ w("| Component | Wt Matrices | Mean alpha | Min alpha | Max alpha | Status |")
1955
+ w("|-----------|------------:|-----------:|----------:|----------:|--------|")
1956
+ for comp_name, layers in ww_results.items():
1957
+ alphas = [l["alpha"] for l in layers if l.get("alpha") is not None]
1958
+ if not alphas:
1959
+ w(f"| {comp_name} | -- | N/A | N/A | N/A | too small |")
1960
+ continue
1961
+ mean_a = sum(alphas) / len(alphas)
1962
+ min_a = min(alphas)
1963
+ max_a = max(alphas)
1964
+ _, _, label = classify_health(alpha=mean_a)
1965
+ badge = _status_emoji(label)
1966
+ w(f"| {comp_name} | {len(layers)} | {mean_a:.2f} | {min_a:.2f} | {max_a:.2f} | {badge} {label} |")
1967
+ else:
1968
+ w("*Skipped (weightwatcher not available)*\n")
1969
+
1970
+ # --- Section 2: Attention Entropy ---
1971
+ w("\n## 2. Attention Entropy (fraction of max)\n")
1972
+ w("Measures how spread out each attention head's focus is. "
1973
+ "Low entropy means the head attends to very few tokens (collapsed/dead). "
1974
+ "High entropy means the head spreads attention nearly uniformly (unfocused). "
1975
+ "Healthy heads are selective but not degenerate — attending to a meaningful subset.\n")
1976
+ if entropy_results:
1977
+ low = thresholds.get("entropy_low", 0.1)
1978
+ warn = thresholds.get("entropy_warn", 0.8)
1979
+ crit = thresholds.get("entropy_critical", 0.95)
1980
+ w(f"> Collapsed: <{low:.2f} | Healthy: {low:.2f}-{warn:.2f} | Unfocused: >{warn:.2f} | Dead: >{crit:.2f}\n")
1981
+
1982
+ for comp_name, comp_entries in entropy_results.items():
1983
+ w(f"\n### {comp_name}\n")
1984
+ by_layer = defaultdict(list)
1985
+ for e in comp_entries:
1986
+ by_layer[e["layer"]].append(e["entropy_ratio"])
1987
+
1988
+ w("| Layer | Mean Ent | Min Ent | Max Ent | Status |")
1989
+ w("|------:|---------:|--------:|--------:|--------|")
1990
+ for layer in sorted(by_layer.keys()):
1991
+ vals = by_layer[layer]
1992
+ mean_e = sum(vals) / len(vals)
1993
+ min_e = min(vals)
1994
+ max_e = max(vals)
1995
+ _, _, label = classify_health(entropy=mean_e, thresholds=thresholds)
1996
+ badge = _status_emoji(label)
1997
+ w(f"| {layer} | {mean_e:.4f} | {min_e:.4f} | {max_e:.4f} | {badge} {label} |")
1998
+ else:
1999
+ w("*No data*\n")
2000
+
2001
+ # --- Section 3: Head Redundancy ---
2002
+ w("\n## 3. Head Redundancy (cosine similarity)\n")
2003
+ w("Measures how similar the attention heads are to each other within each layer. "
2004
+ "Each layer has multiple heads that should learn different patterns (e.g., one "
2005
+ "head for spatial relations, another for color). High similarity means heads are "
2006
+ "redundant — wasted capacity. Collapsed means nearly identical heads.\n")
2007
+ if redundancy_results:
2008
+ r_warn = thresholds.get("redundancy_warn", 0.7)
2009
+ r_crit = thresholds.get("redundancy_critical", 0.9)
2010
+ w(f"> Diverse: <{r_warn:.2f} | High: >{r_warn:.2f} | Collapsed: >{r_crit:.2f}\n")
2011
+
2012
+ for comp_name, comp_entries in redundancy_results.items():
2013
+ w(f"\n### {comp_name}\n")
2014
+ w("| Layer | Mean Sim | Max Sim | Status |")
2015
+ w("|------:|---------:|--------:|--------|")
2016
+ for r in comp_entries:
2017
+ _, _, label = classify_health(redundancy=r["mean_redundancy"], thresholds=thresholds)
2018
+ badge = _status_emoji(label)
2019
+ w(f"| {r['layer']} | {r['mean_redundancy']:.4f} | {r['max_redundancy']:.4f} | {badge} {label} |")
2020
+ else:
2021
+ w("*No data*\n")
2022
+
2023
+ return "\n".join(lines)
2024
+
2025
+
2026
+ def plot_health_report(ww_results, entropy_results, redundancy_results, output_path):
2027
+ """
2028
+ 3-panel vertical matplotlib figure saved to *output_path*.
2029
+
2030
+ Panel 1: alpha per layer (bars) with reference lines at 2 and 6
2031
+ Panel 2: mean entropy ratio per layer
2032
+ Panel 3: mean head redundancy per layer
2033
+ """
2034
+ from collections import defaultdict
2035
+
2036
+ fig, axes = plt.subplots(3, 1, figsize=(14, 12), constrained_layout=True)
2037
+
2038
+ # --- Panel 1: Spectral Alpha ---
2039
+ ax1 = axes[0]
2040
+ if ww_results is not None:
2041
+ bar_labels = []
2042
+ bar_vals = []
2043
+ bar_colors = []
2044
+ separator_positions = []
2045
+ label_positions = []
2046
+ offset = 0
2047
+
2048
+ for comp_name, layers in ww_results.items():
2049
+ alphas = [l["alpha"] for l in layers if l.get("alpha") is not None]
2050
+ if not alphas:
2051
+ continue
2052
+ comp_start = offset
2053
+ for i, a in enumerate(alphas):
2054
+ bar_labels.append(f"L{layers[i]['layer']}")
2055
+ bar_vals.append(a)
2056
+ if a < 2:
2057
+ bar_colors.append("#e74c3c") # red
2058
+ elif a <= 4:
2059
+ bar_colors.append("#2ecc71") # green
2060
+ elif a <= 6:
2061
+ bar_colors.append("#f39c12") # yellow
2062
+ else:
2063
+ bar_colors.append("#e74c3c") # red
2064
+ offset += 1
2065
+ comp_end = offset
2066
+ label_positions.append(((comp_start + comp_end - 1) / 2, comp_name))
2067
+ separator_positions.append(offset - 0.5)
2068
+
2069
+ # Remove last separator
2070
+ if separator_positions:
2071
+ separator_positions.pop()
2072
+
2073
+ if bar_vals:
2074
+ x = range(len(bar_vals))
2075
+ ax1.bar(x, bar_vals, color=bar_colors, edgecolor="white", linewidth=0.5)
2076
+ ax1.axhline(y=2, color="green", linestyle="--", linewidth=1, label="α=2 (lower healthy)")
2077
+ ax1.axhline(y=6, color="red", linestyle="--", linewidth=1, label="α=6 (upper healthy)")
2078
+ ax1.set_ylabel("Alpha (α)")
2079
+ ax1.legend(loc="upper right", fontsize=8)
2080
+
2081
+ for sep_x in separator_positions:
2082
+ ax1.axvline(x=sep_x, color="#888888", linestyle="-", linewidth=0.8, alpha=0.5)
2083
+
2084
+ if len(bar_labels) > 30:
2085
+ step = max(1, len(bar_labels) // 20)
2086
+ ax1.set_xticks(range(0, len(bar_labels), step))
2087
+ ax1.set_xticklabels([bar_labels[i] for i in range(0, len(bar_labels), step)],
2088
+ rotation=45, ha="right", fontsize=6)
2089
+ else:
2090
+ ax1.set_xticks(x)
2091
+ ax1.set_xticklabels(bar_labels, rotation=45, ha="right", fontsize=6)
2092
+
2093
+ # Component labels above bars
2094
+ for x_center, comp_label in label_positions:
2095
+ ax1.text(x_center, 1.02, comp_label, ha="center", va="bottom",
2096
+ fontsize=7, fontweight="bold", transform=ax1.get_xaxis_transform())
2097
+ else:
2098
+ ax1.text(0.5, 0.5, "No alpha data (layers too small)",
2099
+ ha="center", va="center", transform=ax1.transAxes)
2100
+ else:
2101
+ ax1.text(0.5, 0.5, "WeightWatcher not available",
2102
+ ha="center", va="center", transform=ax1.transAxes)
2103
+ ax1.set_title("Weight Spectral Analysis — Alpha per Layer", fontweight="bold", pad=20)
2104
+
2105
+ # --- Panel 2: Attention Entropy (grouped by component) ---
2106
+ ax2 = axes[1]
2107
+ if entropy_results:
2108
+ bar_labels = []
2109
+ bar_means = []
2110
+ bar_colors = []
2111
+ separator_positions = [] # x positions for vertical lines between components
2112
+ label_positions = [] # (x_center, comp_name) for component labels
2113
+ offset = 0
2114
+
2115
+ for comp_name, comp_entries in entropy_results.items():
2116
+ by_layer = defaultdict(list)
2117
+ for e in comp_entries:
2118
+ by_layer[e["layer"]].append(e["entropy_ratio"])
2119
+ layers_sorted = sorted(by_layer.keys())
2120
+ comp_start = offset
2121
+
2122
+ for layer in layers_sorted:
2123
+ vals = by_layer[layer]
2124
+ mean_e = sum(vals) / len(vals)
2125
+ bar_labels.append(f"L{layer}")
2126
+ bar_means.append(mean_e)
2127
+ if mean_e >= 0.95:
2128
+ bar_colors.append("#e74c3c")
2129
+ elif mean_e >= 0.8:
2130
+ bar_colors.append("#f39c12")
2131
+ elif mean_e <= 0.1:
2132
+ bar_colors.append("#e74c3c")
2133
+ else:
2134
+ bar_colors.append("#2ecc71")
2135
+ offset += 1
2136
+
2137
+ comp_end = offset
2138
+ label_positions.append(((comp_start + comp_end - 1) / 2, comp_name))
2139
+ if comp_end < sum(len(v) for v in [defaultdict(list)] * 0) or offset > 0:
2140
+ separator_positions.append(offset - 0.5)
2141
+
2142
+ # Remove last separator (no line after the last component)
2143
+ if separator_positions:
2144
+ separator_positions.pop()
2145
+
2146
+ ax2.bar(range(len(bar_means)), bar_means, color=bar_colors, edgecolor="white", linewidth=0.5)
2147
+ ax2.axhline(y=0.8, color="#f39c12", linestyle="--", linewidth=1, label="warn (0.8)")
2148
+ ax2.axhline(y=0.95, color="#e74c3c", linestyle="--", linewidth=1, label="critical (0.95)")
2149
+ ax2.axhline(y=0.1, color="#e74c3c", linestyle=":", linewidth=1, label="collapsed (0.1)")
2150
+
2151
+ # Vertical separators between components
2152
+ for sep_x in separator_positions:
2153
+ ax2.axvline(x=sep_x, color="#888888", linestyle="-", linewidth=0.8, alpha=0.5)
2154
+
2155
+ # Component labels at top
2156
+ for x_center, comp_label in label_positions:
2157
+ ax2.text(x_center, 1.02, comp_label, ha="center", va="bottom",
2158
+ fontsize=7, fontweight="bold", transform=ax2.get_xaxis_transform())
2159
+
2160
+ if len(bar_labels) > 40:
2161
+ step = max(1, len(bar_labels) // 30)
2162
+ ax2.set_xticks(range(0, len(bar_labels), step))
2163
+ ax2.set_xticklabels([bar_labels[i] for i in range(0, len(bar_labels), step)],
2164
+ fontsize=6, rotation=45, ha="right")
2165
+ else:
2166
+ ax2.set_xticks(range(len(bar_labels)))
2167
+ ax2.set_xticklabels(bar_labels, fontsize=6, rotation=45, ha="right")
2168
+ ax2.set_ylabel("Entropy / max entropy")
2169
+ ax2.set_ylim(0, 1.12)
2170
+ ax2.legend(loc="upper right", fontsize=8)
2171
+ else:
2172
+ ax2.text(0.5, 0.5, "No entropy data", ha="center", va="center", transform=ax2.transAxes)
2173
+ ax2.set_title("Attention Entropy per Layer (mean across heads)", fontweight="bold", pad=20)
2174
+
2175
+ # --- Panel 3: Head Redundancy (grouped by component) ---
2176
+ ax3 = axes[2]
2177
+ if redundancy_results:
2178
+ bar_labels = []
2179
+ bar_means = []
2180
+ bar_colors = []
2181
+ separator_positions = []
2182
+ label_positions = []
2183
+ offset = 0
2184
+
2185
+ for comp_name, comp_entries in redundancy_results.items():
2186
+ comp_start = offset
2187
+ for r in comp_entries:
2188
+ bar_labels.append(f"L{r['layer']}")
2189
+ bar_means.append(r["mean_redundancy"])
2190
+ m = r["mean_redundancy"]
2191
+ if m >= 0.9:
2192
+ bar_colors.append("#e74c3c")
2193
+ elif m >= 0.7:
2194
+ bar_colors.append("#f39c12")
2195
+ else:
2196
+ bar_colors.append("#2ecc71")
2197
+ offset += 1
2198
+
2199
+ comp_end = offset
2200
+ label_positions.append(((comp_start + comp_end - 1) / 2, comp_name))
2201
+ separator_positions.append(offset - 0.5)
2202
+
2203
+ # Remove last separator
2204
+ if separator_positions:
2205
+ separator_positions.pop()
2206
+
2207
+ ax3.bar(range(len(bar_means)), bar_means, color=bar_colors, edgecolor="white", linewidth=0.5)
2208
+ ax3.axhline(y=0.7, color="#f39c12", linestyle="--", linewidth=1, label="warn (0.7)")
2209
+ ax3.axhline(y=0.9, color="#e74c3c", linestyle="--", linewidth=1, label="critical (0.9)")
2210
+
2211
+ for sep_x in separator_positions:
2212
+ ax3.axvline(x=sep_x, color="#888888", linestyle="-", linewidth=0.8, alpha=0.5)
2213
+
2214
+ for x_center, comp_label in label_positions:
2215
+ ax3.text(x_center, 1.02, comp_label, ha="center", va="bottom",
2216
+ fontsize=7, fontweight="bold", transform=ax3.get_xaxis_transform())
2217
+
2218
+ if len(bar_labels) > 40:
2219
+ step = max(1, len(bar_labels) // 30)
2220
+ ax3.set_xticks(range(0, len(bar_labels), step))
2221
+ ax3.set_xticklabels([bar_labels[i] for i in range(0, len(bar_labels), step)],
2222
+ fontsize=6, rotation=45, ha="right")
2223
+ else:
2224
+ ax3.set_xticks(range(len(bar_labels)))
2225
+ ax3.set_xticklabels(bar_labels, fontsize=6, rotation=45, ha="right")
2226
+ ax3.set_ylabel("Mean cosine similarity")
2227
+ ax3.set_ylim(0, 1.12)
2228
+ ax3.legend(loc="upper right", fontsize=8)
2229
+ else:
2230
+ ax3.text(0.5, 0.5, "No redundancy data", ha="center", va="center", transform=ax3.transAxes)
2231
+ ax3.set_title("Head Redundancy per Layer", fontweight="bold", pad=20)
2232
+
2233
+ plt.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
2234
+ plt.close()
2235
+ print(f" Saved health report plot: {output_path}")
2236
+
2237
+
2238
+ def run_model_health_report(policy, dataset, args):
2239
+ """
2240
+ Orchestrator for ``--model-health`` mode.
2241
+
2242
+ Step 1: Spectral alpha via WeightWatcher (no data needed).
2243
+ Step 2: Sample frames, run forward passes, compute entropy + redundancy.
2244
+ Step 3: Print terminal report and save plot.
2245
+ """
2246
+ device = torch.device(args.device)
2247
+
2248
+ thresholds = {
2249
+ "entropy_warn": args.entropy_warn,
2250
+ "entropy_critical": args.entropy_critical,
2251
+ "entropy_low": args.entropy_low,
2252
+ "redundancy_warn": args.redundancy_warn,
2253
+ "redundancy_critical": args.redundancy_critical,
2254
+ }
2255
+
2256
+ # ------------------------------------------------------------------
2257
+ # Step 1: WeightWatcher spectral analysis
2258
+ # ------------------------------------------------------------------
2259
+ print(f"\n{'=' * 70}")
2260
+ print("MODEL HEALTH DIAGNOSTICS")
2261
+ print(f"{'=' * 70}")
2262
+ print("\n[1/3] Running WeightWatcher spectral analysis...")
2263
+ ww_results = compute_weightwatcher_alpha(policy)
2264
+
2265
+ # ------------------------------------------------------------------
2266
+ # Step 2: Sample frames, capture per-head attention, compute metrics
2267
+ # ------------------------------------------------------------------
2268
+ print(f"\n[2/3] Capturing attention maps over {args.health_frames} frames...")
2269
+
2270
+ # Find vision encoder and set up hooks
2271
+ vision_encoder = find_vision_encoder(policy)
2272
+ if vision_encoder is None:
2273
+ print(" ERROR: Could not find vision encoder. Skipping attention metrics.")
2274
+ entropy_results = {}
2275
+ redundancy_results = {}
2276
+ else:
2277
+ # Force eager attention on SigLIP
2278
+ for mod in vision_encoder.modules():
2279
+ if getattr(mod, "config", None) is not None and hasattr(mod.config, "_attn_implementation"):
2280
+ mod.config._attn_implementation = "eager"
2281
+
2282
+ # Force per-head weights (disable averaging) on SigLIP MHA
2283
+ mha_modules = []
2284
+ for mod in vision_encoder.modules():
2285
+ if isinstance(mod, torch.nn.MultiheadAttention):
2286
+ mha_modules.append((mod, getattr(mod, "average_attn_weights", True)))
2287
+ mod.average_attn_weights = False
2288
+
2289
+ # Set up capture objects
2290
+ sigclip_capture = SigLIPAttentionCapture()
2291
+ sigclip_capture.register_hooks(vision_encoder)
2292
+
2293
+ vlm_with_expert = policy.model.vlm_with_expert
2294
+
2295
+ # SmolVLA bypasses self_attn.forward() — all attention flows through
2296
+ # eager_attention_forward(). DecoderAttentionCapture patches that
2297
+ # method to capture both self-attention (prefill) and cross-attention
2298
+ # (expert → VLM prefix).
2299
+ decoder_capture = DecoderAttentionCapture()
2300
+ print(" Setting up decoder attention capture (eager_attention_forward)...")
2301
+ decoder_capture.register(vlm_with_expert)
2302
+
2303
+ # Resolve image key
2304
+ image_key = args.image_key
2305
+ if image_key is None:
2306
+ image_keys = find_image_keys(dataset)
2307
+ if image_keys:
2308
+ image_key = image_keys[0]
2309
+ else:
2310
+ print(" ERROR: No image keys found in dataset.")
2311
+ sigclip_capture.clear()
2312
+ decoder_capture.clear()
2313
+ for mod, orig in mha_modules:
2314
+ mod.average_attn_weights = orig
2315
+ entropy_results = {}
2316
+ redundancy_results = {}
2317
+ image_key = None
2318
+
2319
+ if image_key is not None:
2320
+ # Sample frames
2321
+ frame_pairs = get_episode_frames(
2322
+ dataset, args.episode, args.health_frames, image_key,
2323
+ )
2324
+
2325
+ # Determine SigLIP patch grid for entropy normalisation
2326
+ patch_size = getattr(vision_encoder, "patch_size", None) or getattr(
2327
+ getattr(vision_encoder, "config", None), "patch_size", 14,
2328
+ )
2329
+ img_size = getattr(
2330
+ getattr(vision_encoder, "config", None), "image_size", 384,
2331
+ )
2332
+ n_patches_side = img_size // patch_size
2333
+ sigclip_num_patches = n_patches_side * n_patches_side
2334
+
2335
+ # Component names for the three attention sources
2336
+ COMP_SIGLIP = "SigLIP Vision (12L, 12H)"
2337
+ COMP_DECODER_SA = "VLM+Expert Joint Self-Attn (16L, 15H)"
2338
+ COMP_EXPERT_XA = "Expert-to-VLM Cross-Attn (16L, 8H)"
2339
+
2340
+ all_entropy = {COMP_SIGLIP: [], COMP_DECODER_SA: [], COMP_EXPERT_XA: []}
2341
+ all_redundancy = {COMP_SIGLIP: [], COMP_DECODER_SA: [], COMP_EXPERT_XA: []}
2342
+
2343
+ policy.eval()
2344
+
2345
+ for i, (frame_idx, img_tensor) in enumerate(frame_pairs):
2346
+ sigclip_capture.reset_maps()
2347
+ decoder_capture.reset_maps()
2348
+ sample = dataset[frame_idx]
2349
+
2350
+ with torch.no_grad():
2351
+ # Use full policy forward so we capture all layers
2352
+ policy.reset()
2353
+ batch, _ = build_policy_batch_from_sample(
2354
+ sample, policy, device, batch_size=1,
2355
+ image_key_for_grad=None, dataset=dataset,
2356
+ )
2357
+ try:
2358
+ policy.select_action(batch)
2359
+ except Exception as e:
2360
+ print(f" Frame {i} forward pass error: {e}")
2361
+ continue
2362
+
2363
+ # --- SigLIP ---
2364
+ counts = []
2365
+ siglip_attns = sigclip_capture.get_all_layer_attentions()
2366
+ if siglip_attns:
2367
+ frame_ent = compute_attention_entropy(siglip_attns, sigclip_num_patches)
2368
+ frame_red = compute_head_redundancy(siglip_attns)
2369
+ all_entropy[COMP_SIGLIP].append(frame_ent)
2370
+ all_redundancy[COMP_SIGLIP].append(frame_red)
2371
+ counts.append(f"SigLIP={len(siglip_attns)}")
2372
+ else:
2373
+ counts.append("SigLIP=0")
2374
+
2375
+ # --- Decoder Self-Attention (prefill: VLM+Expert concatenated) ---
2376
+ sa_attns = decoder_capture.get_self_attn_layers()
2377
+ if sa_attns:
2378
+ sa_num_tokens = sa_attns[0][1].shape[-1]
2379
+ frame_ent = compute_attention_entropy(sa_attns, sa_num_tokens)
2380
+ frame_red = compute_head_redundancy(sa_attns)
2381
+ all_entropy[COMP_DECODER_SA].append(frame_ent)
2382
+ all_redundancy[COMP_DECODER_SA].append(frame_red)
2383
+ counts.append(f"Decoder SA={len(sa_attns)}")
2384
+ else:
2385
+ counts.append("Decoder SA=0")
2386
+
2387
+ # --- Expert Cross-Attention ---
2388
+ xa_attns_raw = decoder_capture.get_cross_attn_layers()
2389
+ if xa_attns_raw:
2390
+ # Remap sequential indices → actual layer indices.
2391
+ # During generation, each autoregressive step runs through
2392
+ # all decoder layers, so call_idx % num_layers = layer.
2393
+ num_dec_layers = vlm_with_expert.num_vlm_layers
2394
+ xa_attns = [(idx % num_dec_layers, probs) for idx, probs in xa_attns_raw]
2395
+ xa_num_tokens = xa_attns[0][1].shape[-1]
2396
+ frame_ent = compute_attention_entropy(xa_attns, xa_num_tokens)
2397
+ frame_red = compute_head_redundancy(xa_attns)
2398
+ all_entropy[COMP_EXPERT_XA].append(frame_ent)
2399
+ all_redundancy[COMP_EXPERT_XA].append(frame_red)
2400
+ counts.append(f"Expert XA={len(xa_attns_raw)}")
2401
+ else:
2402
+ counts.append("Expert XA=0")
2403
+
2404
+ print(f" Frame {i}: {', '.join(counts)}")
2405
+
2406
+ # Cleanup
2407
+ sigclip_capture.clear()
2408
+ decoder_capture.clear()
2409
+
2410
+ # Restore MHA averaging setting
2411
+ for mod, orig in mha_modules:
2412
+ mod.average_attn_weights = orig
2413
+
2414
+ # Average metrics across frames, per component
2415
+ from collections import defaultdict
2416
+
2417
+ entropy_results = {}
2418
+ for comp_name, frame_list in all_entropy.items():
2419
+ if not frame_list:
2420
+ continue
2421
+ ent_accum = defaultdict(lambda: defaultdict(list))
2422
+ for frame_ent in frame_list:
2423
+ for e in frame_ent:
2424
+ ent_accum[(e["layer"], e["head"])]["entropy_ratio"].append(e["entropy_ratio"])
2425
+ ent_accum[(e["layer"], e["head"])]["entropy"].append(e["entropy"])
2426
+
2427
+ comp_results = []
2428
+ for (layer, head), vals in sorted(ent_accum.items()):
2429
+ comp_results.append({
2430
+ "layer": layer,
2431
+ "head": head,
2432
+ "entropy": sum(vals["entropy"]) / len(vals["entropy"]),
2433
+ "entropy_ratio": sum(vals["entropy_ratio"]) / len(vals["entropy_ratio"]),
2434
+ })
2435
+ if comp_results:
2436
+ entropy_results[comp_name] = comp_results
2437
+
2438
+ redundancy_results = {}
2439
+ for comp_name, frame_list in all_redundancy.items():
2440
+ if not frame_list:
2441
+ continue
2442
+ red_accum = defaultdict(lambda: {"mean": [], "max": []})
2443
+ for frame_red in frame_list:
2444
+ for r in frame_red:
2445
+ red_accum[r["layer"]]["mean"].append(r["mean_redundancy"])
2446
+ red_accum[r["layer"]]["max"].append(r["max_redundancy"])
2447
+
2448
+ comp_results = []
2449
+ for layer in sorted(red_accum.keys()):
2450
+ vals = red_accum[layer]
2451
+ comp_results.append({
2452
+ "layer": layer,
2453
+ "mean_redundancy": sum(vals["mean"]) / len(vals["mean"]),
2454
+ "max_redundancy": sum(vals["max"]) / len(vals["max"]),
2455
+ })
2456
+ if comp_results:
2457
+ redundancy_results[comp_name] = comp_results
2458
+
2459
+ # ------------------------------------------------------------------
2460
+ # Step 3: Print report and save plot
2461
+ # ------------------------------------------------------------------
2462
+ print(f"\n[3/3] Generating report...")
2463
+ print_health_report(ww_results, entropy_results, redundancy_results, thresholds)
2464
+
2465
+ os.makedirs(args.output_dir, exist_ok=True)
2466
+
2467
+ md_path = os.path.join(args.output_dir, "model_health_report.md")
2468
+ md_text = generate_health_markdown(ww_results, entropy_results, redundancy_results, thresholds)
2469
+ with open(md_path, "w") as f:
2470
+ f.write(md_text)
2471
+ print(f" Saved markdown report: {md_path}")
2472
+
2473
+ plot_path = os.path.join(args.output_dir, "model_health_report.png")
2474
+ plot_health_report(ww_results, entropy_results, redundancy_results, plot_path)
2475
+
2476
+ print(f"Done! Reports saved to {args.output_dir}/")
2477
+
2478
+
2479
+ # ---------------------------------------------------------------------------
2480
+ # 9. Entry point
2481
  # ---------------------------------------------------------------------------
2482
 
2483
  def load_defaults():
 
2537
  default=defaults.get("raw_attention", False),
2538
  help="Skip positional baseline subtraction (show raw attention)")
2539
 
2540
+ # Model health diagnostics
2541
+ parser.add_argument("--model-health", action="store_true",
2542
+ default=defaults.get("model_health", False),
2543
+ help="Run health diagnostics instead of attention heatmaps")
2544
+ parser.add_argument("--health-frames", type=int,
2545
+ default=defaults.get("health_frames", 5),
2546
+ help="Number of sample frames for entropy/redundancy (default: 5)")
2547
+ parser.add_argument("--entropy-warn", type=float,
2548
+ default=defaults.get("entropy_warn", 0.8),
2549
+ help="Entropy ratio threshold for 'unfocused' warning (default: 0.8)")
2550
+ parser.add_argument("--entropy-critical", type=float,
2551
+ default=defaults.get("entropy_critical", 0.95),
2552
+ help="Entropy ratio threshold for 'uniform/dead' (default: 0.95)")
2553
+ parser.add_argument("--entropy-low", type=float,
2554
+ default=defaults.get("entropy_low", 0.1),
2555
+ help="Entropy ratio threshold for 'collapsed' (default: 0.1)")
2556
+ parser.add_argument("--redundancy-warn", type=float,
2557
+ default=defaults.get("redundancy_warn", 0.7),
2558
+ help="Cosine similarity threshold for 'high redundancy' (default: 0.7)")
2559
+ parser.add_argument("--redundancy-critical", type=float,
2560
+ default=defaults.get("redundancy_critical", 0.9),
2561
+ help="Cosine similarity threshold for 'collapsed' (default: 0.9)")
2562
+
2563
  args = parser.parse_args()
2564
 
2565
  os.makedirs(args.output_dir, exist_ok=True)
 
2637
  except Exception as e:
2638
  print(f"\n ERROR loading dataset: {e}")
2639
  sys.exit(1)
2640
+
2641
+ # --- Model health mode (early exit) ---
2642
+ if args.model_health:
2643
+ run_model_health_report(policy, dataset, args)
2644
+ return
2645
+
2646
  # --- Extract attention maps ---
2647
  print(f"\n[Step 3] Extracting attention maps...")
2648
 
requirements.txt CHANGED
@@ -7,3 +7,4 @@ matplotlib>=3.7
7
  numpy>=1.24
8
  Pillow>=10.0
9
  pyyaml>=6.0
 
 
7
  numpy>=1.24
8
  Pillow>=10.0
9
  pyyaml>=6.0
10
+ weightwatcher>=0.7