Skip to content

Commit 9039b4c

Browse files
Merge pull request #19 from konjoai/claude/konjo-miru-phase30
feat(rollout): Phase 30 β€” attention rollout explainer (v1.13.0)
2 parents 98338e3 + 5000056 commit 9039b4c

11 files changed

Lines changed: 551 additions & 7 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,56 @@ Format: [Conventional Commits](https://www.conventionalcommits.org/) + [Keep a C
55

66
---
77

8+
## [1.13.0] β€” Phase 30: attention rollout
9+
10+
### Added
11+
12+
#### `miru/rollout.py` β€” multi-layer attention rollout
13+
- `AttentionRollout(residual_weight, extractor)` β€” propagates per-layer
14+
attention maps through all transformer layers using a geometric mean
15+
(log-space average) with a residual identity term modelling skip-connections:
16+
`Γ’_l = (1βˆ’r)Β·norm(A_l) + rΒ·uniform`, then `rollout = exp(mean_l log(Γ’_l))`,
17+
min-max normalised to `[0, 1]`.
18+
- `RolloutResult` frozen dataclass: `rollout_grid`, `n_layers`,
19+
`used_layer_weights`, `residual_weight`, `grid_h`, `grid_w`.
20+
- Validation: `residual_weight ∈ [0, 1]`, raises `ValueError` otherwise.
21+
- Degrades gracefully to single-layer attention with a warning when
22+
`VLMOutput.layer_attention_weights` is absent.
23+
- Cites Abnar & Zuidema 2020 (arXiv:2005.00928).
24+
25+
#### `VLMOutput` (`miru/models/base.py`)
26+
- New optional field `layer_attention_weights: list[np.ndarray] | None = None` β€”
27+
per-transformer-layer 2-D float32 attention maps, first to last.
28+
Backward-compatible (defaults to `None`).
29+
30+
#### `MockVLMBackend` (`miru/models/mock.py`)
31+
- `infer()` now populates `layer_attention_weights` with 4 synthetic layers
32+
(progressively wider Gaussians, XOR-seeded per layer).
33+
34+
#### `POST /explain` β€” `api/main.py`
35+
- `method="rollout"` dispatched via `_run_method`.
36+
- `ExplainRequest` extended with `residual_weight` (float, ge=0.0, le=1.0,
37+
default 0.5).
38+
- `"rollout"` added to `IMPLEMENTED_METHODS` and `_METHOD_DESCRIPTIONS`.
39+
- `GET /methods` now lists `rollout` with status `implemented`.
40+
41+
### Tests
42+
- `tests/test_rollout.py` β€” 25 tests: unit (dtype, value range, shape,
43+
custom resolution, uses layer weights from mock, n_layers reported,
44+
residual weight reported, determinism, residual=0 vs 1 differ, fallback
45+
with no layer weights, invalid residual weight, boundaries, VLMOutput
46+
defaults, mock provides 4 layers), API (200, response shape, overlay,
47+
custom residual weight, /methods listing, error contracts, health regression).
48+
49+
### Changed
50+
- Version bumped to `1.13.0`.
51+
- `tests/test_api.py` health-version assertion β†’ `1.13.0`.
52+
53+
### Test results
54+
- 828 passed, 20 skipped, 1 warning.
55+
56+
---
57+
858
## [1.12.0] β€” Phase 29: Qwen3-VL real backend
959

1060
### Added

β€ŽPLAN.mdβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# PLAN.md β€” Miru Roadmap
22

33
**Project:** Miru β€” Multimodal Reasoning Tracer
4-
**Current version:** v1.12.0
5-
**Status:** Qwen3-VL real backend (Phase 29), joint intra-modal + cross-modal attribution (Phase 28), EU AI Act compliance harden (Phase 27), synergistic-faithfulness probe / F_syn (Phase 26), 814 tests passing (9 skipped without MIRU_TEST_REAL_BACKENDS=1)
4+
**Current version:** v1.13.0
5+
**Status:** Attention rollout (Phase 30), minimal counterfactual (Phase 29), joint attribution (Phase 28), EU AI Act compliance harden (Phase 27), 828 tests passing (20 skipped without MIRU_TEST_REAL_BACKENDS=1)
66

77
---
88

β€Žapi/main.pyβ€Ž

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from miru.integrated_attention import IntegratedAttention
4747
from miru.joint_attribution import JointAttribution
4848
from miru.counterfactual import MinimalCounterfactual
49+
from miru.rollout import AttentionRollout
4950
from miru.shap_explainer import SHAPConfig, SHAPExplainer
5051
from miru.attention.extractor import AttentionExtractor
5152
from miru.bench.comparison import compare_backends
@@ -87,7 +88,7 @@
8788
DEFAULT_BENCH_SIZE = 64
8889
MAX_BENCH_SIZE = 128
8990

90-
IMPLEMENTED_METHODS: tuple[str, ...] = ("attention", "lime", "gradcam", "shap", "integrated", "joint")
91+
IMPLEMENTED_METHODS: tuple[str, ...] = ("attention", "lime", "gradcam", "shap", "integrated", "joint", "rollout")
9192
ROADMAP_METHODS: tuple[str, ...] = ()
9293

9394
# Bound the budgets on the perturbation-based methods so a public deploy
@@ -235,6 +236,12 @@ class ExplainRequest(BaseModel):
235236
le=1.0,
236237
description="Joint-attribution intra-visual weight Ξ± ∈ [0, 1]. Cross-modal receives 1βˆ’Ξ±.",
237238
)
239+
residual_weight: float = Field(
240+
0.5,
241+
ge=0.0,
242+
le=1.0,
243+
description="Rollout residual (identity) weight ∈ [0, 1] for skip-connection modelling.",
244+
)
238245
roi: BoundingBox | None = Field(
239246
None,
240247
description=(
@@ -809,6 +816,12 @@ def health() -> HealthResponse:
809816
"weight Ξ±. Produces more faithful saliency than cross-modal alone when "
810817
"intra-visual weights are available."
811818
),
819+
"rollout": (
820+
"Attention rollout (Abnar & Zuidema 2020, arXiv:2005.00928): propagates "
821+
"per-layer attention maps through all transformer layers using a geometric "
822+
"mean with a residual identity term modelling skip-connections. Produces a "
823+
"more faithful saliency map than single-layer attention for deep ViT models."
824+
),
812825
}
813826

814827

@@ -1567,6 +1580,14 @@ def _run_method(method: str, backend, image_array: np.ndarray, req):
15671580
)
15681581
return baseline, result.joint_grid
15691582

1583+
if method == "rollout":
1584+
baseline = backend.infer(image_array, req.question)
1585+
resid_w = getattr(req, "residual_weight", 0.5)
1586+
result = AttentionRollout(residual_weight=resid_w).explain(
1587+
backend, image_array, req.question
1588+
)
1589+
return baseline, result.rollout_grid
1590+
15701591
raise HTTPException(status_code=400, detail=f"unsupported method: {method}")
15711592

15721593

β€Žmiru/__init__.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Miru β€” multimodal reasoning tracer and VLM explainability engine."""
22
from __future__ import annotations
33

4-
__version__ = "1.12.0"
4+
__version__ = "1.13.0"
55

66
from miru.attention.extractor import AttentionExtractor
77
from miru.gradcam import GradCAMExplainer, GradCAMResult, compute_gradcam

β€Žmiru/config.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class Settings(BaseModel):
88
model_config = ConfigDict(frozen=True)
99

1010
app_name: str = "miru"
11-
version: str = "1.12.0"
11+
version: str = "1.13.0"
1212
default_backend: str = "mock"
1313
max_image_size_bytes: int = 10 * 1024 * 1024 # 10 MB
1414
attention_resolution: int = 16 # NxN grid size

β€Žmiru/models/base.pyβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ class VLMOutput:
1919
intra_visual_weights: Optional 2-D float32 array (H Γ— W) of per-patch
2020
intra-visual saliency (patches attending to each other). ``None``
2121
when the backend does not expose intra-visual attention.
22+
layer_attention_weights: Optional list of per-transformer-layer 2-D
23+
float32 arrays ``(H Γ— W)``, from first to last layer. ``None``
24+
when the backend exposes only the final-layer attention.
2225
"""
2326

2427
answer: str
2528
confidence: float
2629
attention_weights: np.ndarray
2730
reasoning_steps: list[str]
2831
intra_visual_weights: Optional[np.ndarray] = None
32+
layer_attention_weights: Optional[list[np.ndarray]] = None
2933

3034

3135
@dataclass(frozen=True)

β€Žmiru/models/mock.pyβ€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ def infer(self, image_array: np.ndarray, question: str) -> VLMOutput: # noqa: A
5454

5555
attention_weights = self._make_gaussian_map(q_key)
5656
intra_visual_weights = self._make_gaussian_map(q_key ^ 0xA5A5, sigma=5.0)
57+
# Four synthetic transformer layers: progressively wider Gaussians.
58+
layer_attention_weights = [
59+
self._make_gaussian_map(q_key ^ (layer_idx * 0x1111), sigma=2.0 + layer_idx)
60+
for layer_idx in range(4)
61+
]
5762

5863
reasoning_steps = list(_REASONING_TEMPLATES[0])
5964

@@ -63,6 +68,7 @@ def infer(self, image_array: np.ndarray, question: str) -> VLMOutput: # noqa: A
6368
attention_weights=attention_weights,
6469
reasoning_steps=reasoning_steps,
6570
intra_visual_weights=intra_visual_weights,
71+
layer_attention_weights=layer_attention_weights,
6672
)
6773

6874
# ------------------------------------------------------------------

β€Žmiru/rollout.pyβ€Ž

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Attention rollout for multi-layer Transformer VLMs.
2+
3+
Computes a multi-layer saliency map by propagating attention through all
4+
transformer layers using the geometric-mean ("attention flow") aggregation
5+
from Abnar & Zuidema (2020).
6+
7+
Algorithm
8+
---------
9+
Given *L* per-layer attention maps ``A_1, …, A_L`` (each ``(H, W)`` float32,
10+
non-negative, from the first to the last encoder layer):
11+
12+
1. Min-max normalise each map: ``a_l = normalise(A_l)``.
13+
2. Add a residual identity term (models the skip-connection):
14+
``Γ’_l = (1 βˆ’ residual_weight) * a_l + residual_weight * uniform``,
15+
where ``uniform = ones(H,W)/(H*W)`` and ``residual_weight`` defaults
16+
to ``0.5``.
17+
3. Compute the geometric mean across layers in log-space:
18+
``rollout = exp(mean_l [log(Γ’_l + Ξ΅)])``
19+
4. Min-max normalise the result to ``[0, 1]``.
20+
21+
The geometric mean is preferred over the arithmetic mean because it
22+
preserves sparsity: a cell must receive *consistent* attention across
23+
all layers to score high, matching the rollout intuition that information
24+
must flow through every layer.
25+
26+
When ``layer_attention_weights`` is ``None`` (backend does not expose
27+
per-layer weights), the explainer falls back to the single final-layer
28+
attention map with a warning.
29+
30+
References
31+
----------
32+
Abnar, S., & Zuidema, W. (2020). *Quantifying Attention Flow in
33+
Transformers*. ACL 2020. arXiv:2005.00928.
34+
"""
35+
from __future__ import annotations
36+
37+
import logging
38+
from dataclasses import dataclass
39+
40+
import numpy as np
41+
42+
from miru.attention.extractor import AttentionExtractor
43+
from miru.models.base import VLMBackend
44+
45+
logger = logging.getLogger(__name__)
46+
47+
DEFAULT_RESIDUAL_WEIGHT: float = 0.5
48+
_LOG_EPS: float = 1e-8
49+
50+
51+
@dataclass(frozen=True)
52+
class RolloutResult:
53+
"""Output of the attention rollout explainer.
54+
55+
Attributes:
56+
rollout_grid: Multi-layer saliency map, float32
57+
``(grid_h, grid_w)`` in ``[0, 1]``.
58+
n_layers: Number of layers actually used.
59+
used_layer_weights: Whether per-layer weights were available.
60+
residual_weight: Residual identity weight applied.
61+
grid_h: Height of the output grid.
62+
grid_w: Width of the output grid.
63+
"""
64+
65+
rollout_grid: np.ndarray
66+
n_layers: int
67+
used_layer_weights: bool
68+
residual_weight: float
69+
grid_h: int
70+
grid_w: int
71+
72+
73+
class AttentionRollout:
74+
"""Multi-layer attention rollout explainer.
75+
76+
Args:
77+
residual_weight: Weight for the uniform identity term ``∈ [0, 1]``.
78+
Represents the Transformer skip-connection contribution.
79+
Defaults to ``0.5``.
80+
extractor: :class:`~miru.attention.extractor.AttentionExtractor`
81+
for normalisation and grid resizing.
82+
"""
83+
84+
def __init__(
85+
self,
86+
residual_weight: float = DEFAULT_RESIDUAL_WEIGHT,
87+
extractor: AttentionExtractor | None = None,
88+
) -> None:
89+
if not 0.0 <= residual_weight <= 1.0:
90+
raise ValueError(
91+
f"residual_weight must be in [0, 1], got {residual_weight}"
92+
)
93+
self._residual_weight = residual_weight
94+
self._extractor = extractor or AttentionExtractor()
95+
96+
def explain(
97+
self,
98+
backend: VLMBackend,
99+
image_array: np.ndarray,
100+
question: str,
101+
) -> RolloutResult:
102+
"""Compute attention rollout for *image_array*.
103+
104+
Args:
105+
backend: Any registered :class:`~miru.models.base.VLMBackend`.
106+
image_array: float32 ``(H, W, 3)`` image in ``[0, 1]``.
107+
question: Natural-language question.
108+
109+
Returns:
110+
:class:`RolloutResult` with the rolled-out saliency grid.
111+
"""
112+
out = backend.infer(image_array, question)
113+
114+
if out.layer_attention_weights is not None and len(out.layer_attention_weights) > 0:
115+
raw_layers = out.layer_attention_weights
116+
used_layer_weights = True
117+
else:
118+
logger.warning(
119+
"rollout: backend '%s' did not supply layer_attention_weights;"
120+
" falling back to single-layer attention.",
121+
backend.name,
122+
)
123+
raw_layers = [out.attention_weights]
124+
used_layer_weights = False
125+
126+
resolution = self._extractor.resolution
127+
n_cells = resolution * resolution
128+
uniform = np.full((resolution, resolution), 1.0 / n_cells, dtype=np.float32)
129+
130+
log_sum = np.zeros((resolution, resolution), dtype=np.float64)
131+
for raw in raw_layers:
132+
norm = self._extractor.extract(raw)
133+
blended = (
134+
(1.0 - self._residual_weight) * norm
135+
+ self._residual_weight * uniform
136+
).astype(np.float64)
137+
log_sum += np.log(blended + _LOG_EPS)
138+
139+
geom_mean = np.exp(log_sum / len(raw_layers)).astype(np.float32)
140+
141+
lo, hi = float(geom_mean.min()), float(geom_mean.max())
142+
if hi - lo > 1e-8:
143+
geom_mean = ((geom_mean - lo) / (hi - lo)).astype(np.float32)
144+
145+
h, w = geom_mean.shape
146+
return RolloutResult(
147+
rollout_grid=geom_mean,
148+
n_layers=len(raw_layers),
149+
used_layer_weights=used_layer_weights,
150+
residual_weight=self._residual_weight,
151+
grid_h=h,
152+
grid_w=w,
153+
)

β€Žpyproject.tomlβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "miru"
7-
version = "1.12.0"
7+
version = "1.13.0"
88
description = "Multimodal reasoning tracer and VLM explainability engine"
99
license = { text = "BUSL-1.1" }
1010
requires-python = ">=3.10"

β€Žtests/test_api.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_health_version(client: TestClient) -> None:
2020
data = resp.json()
2121
assert "version" in data
2222
assert isinstance(data["version"], str)
23-
assert data["version"] == "1.12.0"
23+
assert data["version"] == "1.13.0"
2424

2525

2626
def test_health_backends(client: TestClient) -> None:

0 commit comments

Comments
Β (0)