Skip to content

Commit 5000056

Browse files
Merge branch 'main' into claude/konjo-miru-phase30
2 parents 7281a79 + 98338e3 commit 5000056

7 files changed

Lines changed: 462 additions & 6 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

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

5656
---
5757

58+
## [1.12.0] β€” Phase 29: Qwen3-VL real backend
59+
60+
### Added
61+
62+
#### `miru/models/qwen3vl.py` β€” generative VLM backend with cross-modal attention
63+
- `Qwen3VLBackend` β€” miru's first generative VLM backend (Qwen3-VL, Alibaba
64+
Sept 2025). Unlike the dual-encoder `CLIPBackend`, it reasons over question
65+
tokens and image patches jointly, so its saliency is what the synergy probe
66+
(Phase 26) and deletion test (Phase 15) are designed to interrogate.
67+
- Lazy-loads weights on the first `infer()` call β€” never at import β€” so the
68+
module imports cleanly in mock-only environments.
69+
- Attention is read from a **middle decoder layer** (cross-modal fusion peaks
70+
mid-stack β€” Qwen2.5-VL report arXiv:2502.13923, layers ~14-24), as the
71+
last-prompt-token β†’ image-token attention (located via
72+
`config.image_token_id`), head-averaged and reshaped to a square patch grid.
73+
Confidence = first-generated-token softmax probability. Uses the `eager`
74+
attention implementation (required for `output_attentions`).
75+
- The verifiable numeric logic (`_select_middle_layer`, `_attention_row_to_grid`)
76+
is isolated in pure helpers and unit-tested fully offline; the model-load +
77+
generation path is gated behind `MIRU_TEST_REAL_BACKENDS=1`, like CLIP.
78+
79+
#### Registry + extras
80+
- `miru/models/registry.py` registers `qwen3vl` in `register_defaults()`.
81+
- `pyproject.toml` `[backends]` bumped to `transformers>=4.57.0` (the minimum
82+
that ships Qwen3-VL natively).
83+
84+
### Tests
85+
- `tests/test_qwen3vl_backend.py` β€” 19 tests: 5 structural (no load), 10
86+
pure-helper (offline: layer selection bounds/clamping, grid reshape /
87+
truncation / empty-guard), 4 gated real-inference.
88+
89+
### Fixed
90+
- Version bump to 1.12.0 across `pyproject.toml`, `miru/__init__.py`,
91+
`miru/config.py`, and the `/health` assertion.
5892
## [1.12.0] β€” Phase 29: minimal counterfactual explanation
5993

6094
### Added

β€ŽCLAUDE.mdβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Multimodal reasoning tracer β€” extract, visualize, and explain what vision-language models attend to. Attention maps, reasoning traces, visualization overlays, and a dataset recorder for training data collection.
44

5-
**v1.10.0** β€” 748 tests passing (5 skipped without `MIRU_TEST_REAL_BACKENDS=1`).
5+
**v1.11.0** β€” 767 tests passing (9 skipped without `MIRU_TEST_REAL_BACKENDS=1`).
66

77
## Stack
88
Python 3.10+ Β· FastAPI Β· Pydantic v2 Β· transformers (CLIP, optional) Β· Pillow Β· NumPy Β· uvicorn
@@ -32,6 +32,7 @@ python -m miru # CLI entry point
3232
| `miru/models/base.py` | `VLMBackend` abstract interface |
3333
| `miru/models/mock.py` | Deterministic `MockVLMBackend` (stable-hash Gaussian attention) |
3434
| `miru/models/clip.py` | `CLIPBackend` β€” HuggingFace CLIP via transformers (optional) |
35+
| `miru/models/qwen3vl.py` | `Qwen3VLBackend` β€” generative Qwen3-VL with cross-modal attention (optional) |
3536
| `miru/models/registry.py` | `register()`, `get()`, `available()`, `register_defaults()` |
3637
| `miru/attention/extractor.py` | Min-max norm, block-average resize, top-k hotspot detection |
3738
| `miru/reasoning/tracer.py` | Structured reasoning trace with decay confidence |

β€ŽPLAN.mdβ€Ž

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -842,9 +842,68 @@ DRY violations; radon all ≀ B.
842842

843843
---
844844

845-
## Phase 28 β€” TBD
845+
## Phase 28 β€” Joint Intra-modal + Cross-modal Attribution (v1.11.0) βœ… COMPLETE
846+
847+
**Goal:** Blend per-patch intra-visual attention with the cross-modal
848+
signal for more faithful saliency maps (informed by arXiv 2509.22415).
849+
850+
**Delivered:**
851+
- `miru/joint_attribution.py` β€” `JointAttribution(intra_weight, extractor)`
852+
blends intra-visual (`VLMOutput.intra_visual_weights`) with cross-modal
853+
attention via `joint = Ξ±Β·intra + (1βˆ’Ξ±)Β·cross`, min-max normalised to
854+
`[0, 1]`; degrades gracefully to cross-modal only when intra weights are
855+
absent (logs a warning). `JointAttributionResult` frozen dataclass;
856+
`DEFAULT_INTRA_WEIGHT = 0.4`; validates `intra_weight ∈ [0, 1]`.
857+
- `VLMOutput` β€” new optional `intra_visual_weights` field (back-compatible).
858+
- `MockVLMBackend.infer()` β€” now populates `intra_visual_weights` with a
859+
distinct second Gaussian blob.
860+
- `POST /explain` β€” `method="joint"` with `intra_weight` param; listed in
861+
`GET /methods`.
862+
- 26 new tests (`tests/test_joint_attribution.py`).
863+
864+
**Ship gate:** 775 tests (passed, with gated skips); ruff/format clean; zero
865+
new DRY violations.
866+
867+
---
868+
869+
## Phase 29 β€” Qwen3-VL Real Backend (v1.12.0) βœ… COMPLETE
870+
871+
**Goal:** Ship miru's first *generative* VLM backend with genuine
872+
cross-modal attention. CLIP is a dual-encoder that only scores
873+
image/text similarity; Qwen3-VL (Alibaba, Sept 2025) reasons over the
874+
question tokens and image patches jointly, so its saliency is what the
875+
synergy probe (Phase 26) and deletion test (Phase 15) are designed to
876+
interrogate.
877+
878+
**Delivered:**
879+
- `miru/models/qwen3vl.py` β€” `Qwen3VLBackend` mirrors the `CLIPBackend`
880+
lazy-load contract (weights load on first `infer()`, never at import).
881+
Attention is read from a **middle decoder layer** (cross-modal fusion
882+
peaks mid-stack β€” Qwen2.5-VL report arXiv:2502.13923, layers ~14-24),
883+
last-prompt-token β†’ image-token attention (located via
884+
`config.image_token_id`), head-averaged and reshaped to a square grid.
885+
Confidence = first-generated-token softmax probability. `eager`
886+
attention impl (required for `output_attentions`).
887+
- The verifiable numeric logic is isolated in pure helpers
888+
(`_select_middle_layer`, `_attention_row_to_grid`) so it is unit-tested
889+
fully offline; the model-load + generation path is gated behind
890+
`MIRU_TEST_REAL_BACKENDS=1`, exactly like CLIP.
891+
- `miru/models/registry.py` β€” registers `qwen3vl` in `register_defaults()`.
892+
- `pyproject.toml` β€” `[backends]` bumped to `transformers>=4.57.0` (the
893+
minimum that ships Qwen3-VL natively).
894+
- 19 new tests (`tests/test_qwen3vl_backend.py`): 5 structural, 10 pure-
895+
helper, 4 gated real-inference.
896+
897+
**Ship gate:** 814 tests (805 passed, 9 skipped offline); ruff/format
898+
clean; zero new DRY; radon all grade A.
899+
900+
---
901+
902+
## Phase 30 β€” TBD
846903

847904
Open candidates (P2/P3 from the researched roadmap, plus deferred items):
905+
- ~~Qwen3-VL real backend~~ βœ… shipped in Phase 29.
906+
- ~~Intra-modal + cross-modal joint attribution~~ βœ… shipped in Phase 28.
848907
- ~~EU AI Act compliance report generator~~ βœ… hardened in Phase 27.
849908
- ~~Explanation alerts / anomaly detection~~ βœ… shipped in Phase 25.
850909
- ~~Synergistic-faithfulness probe (F_syn)~~ βœ… shipped in Phase 26.
@@ -853,9 +912,6 @@ Open candidates (P2/P3 from the researched roadmap, plus deferred items):
853912
- ~~Expert annotation alignment (P2)~~ βœ… shipped in Phase 18.
854913
- ~~Dataset-level saliency analytics (P2)~~ βœ… shipped in Phase 19.
855914
- ~~Cross-modal attention tracer (P2)~~ βœ… shipped in Phase 17.
856-
- Intra-modal + cross-modal joint attribution (informed by arXiv 2509.22415) β€”
857-
upgrade the attention extractor to sum intra-visual token interactions with
858-
the cross-modal signal for more faithful maps (Medium complexity).
859915
- True Grad-CAM via torch-loaded CLIP-RN50 (P3).
860916
- Sparse Autoencoder (SAE) concept-based explanations via Prisma (arXiv 2504.19475)
861917
for the EU AI Act report narrative (P3).

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

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Qwen3-VL backend using HuggingFace transformers.
2+
3+
Qwen3-VL (Alibaba, Sept 2025) is an open vision-language model with a
4+
ViT vision encoder and accessible cross-modal attention. Unlike CLIP β€”
5+
a dual-encoder that only scores image/text similarity β€” Qwen3-VL is a
6+
generative VLM, so its attention reflects genuine cross-modal reasoning
7+
between the question tokens and the image patches. That makes it the
8+
first miru backend whose saliency the synergy probe (``miru.synergy``)
9+
and the deletion test (``miru.fidelity``) can meaningfully interrogate.
10+
11+
Attention extraction
12+
--------------------
13+
A forward pass with ``output_attentions=True`` yields per-layer maps of
14+
shape ``(batch, heads, seq, seq)``. We:
15+
16+
1. pick a **middle** decoder layer β€” cross-modal fusion concentrates in
17+
the middle of the stack (Qwen2.5-VL technical report, arXiv:2502.13923,
18+
found layers ~14-24 carry the peak vision-language signal);
19+
2. read the **last prompt token's** attention to the **image tokens**
20+
(located via ``config.image_token_id``), averaged over heads;
21+
3. reshape that 1-D vector to the nearest square patch grid.
22+
23+
The numeric reshaping logic lives in pure helpers
24+
(:func:`_select_middle_layer`, :func:`_attention_row_to_grid`) so it is
25+
unit-tested offline; the model load + generation path is exercised only
26+
under ``MIRU_TEST_REAL_BACKENDS=1``.
27+
28+
Requires: ``pip install 'transformers>=4.57' torch`` (Qwen3-VL is
29+
integrated natively in recent transformers). ``output_attentions``
30+
requires the eager attention implementation.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import numpy as np
36+
37+
from miru.models.base import VLMBackend, VLMOutput
38+
39+
_DEFAULT_MODEL = "Qwen/Qwen3-VL-8B-Instruct"
40+
_MIDDLE_LAYER_FRACTION = 0.6 # ~60% deep β€” within the empirical fusion band.
41+
42+
43+
def _select_middle_layer(
44+
num_layers: int, fraction: float = _MIDDLE_LAYER_FRACTION
45+
) -> int:
46+
"""Index of the decoder layer ``fraction`` of the way up the stack.
47+
48+
Cross-modal fusion peaks mid-stack, so we sample there rather than at
49+
the final layer. Clamped to a valid ``[0, num_layers - 1]`` index.
50+
"""
51+
if num_layers < 1:
52+
raise ValueError(f"num_layers must be >= 1, got {num_layers}")
53+
idx = int(round((num_layers - 1) * fraction))
54+
return max(0, min(num_layers - 1, idx))
55+
56+
57+
def _attention_row_to_grid(attn_row: np.ndarray) -> np.ndarray:
58+
"""Reshape a 1-D image-token attention vector to a square float32 grid.
59+
60+
The vector is truncated to the largest square ``g*g <= len`` and
61+
reshaped to ``(g, g)`` β€” mirroring the CLIP backend's patch-grid
62+
derivation.
63+
"""
64+
flat = np.asarray(attn_row, dtype=np.float32).ravel()
65+
if flat.size == 0:
66+
raise ValueError("attn_row must be non-empty")
67+
grid = int(flat.size**0.5)
68+
if grid < 1:
69+
raise ValueError(f"too few image tokens to form a grid: {flat.size}")
70+
return flat[: grid * grid].reshape(grid, grid).astype(np.float32)
71+
72+
73+
class Qwen3VLBackend(VLMBackend):
74+
"""Generative VLM backend over Qwen3-VL with cross-modal attention.
75+
76+
Lazy-loads weights on the first :meth:`infer` call β€” never at import
77+
time β€” so the module imports cleanly in mock-only environments.
78+
"""
79+
80+
def __init__(self, model_name: str = _DEFAULT_MODEL) -> None:
81+
self._model_name = model_name
82+
self._model = None
83+
self._processor = None
84+
85+
def _load(self) -> None:
86+
"""Lazy-load model + processor on first inference call."""
87+
if self._model is not None:
88+
return
89+
import torch # noqa: F401 -- kept out of module scope (optional dep)
90+
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
91+
92+
self._processor = AutoProcessor.from_pretrained(self._model_name)
93+
self._model = Qwen3VLForConditionalGeneration.from_pretrained(
94+
self._model_name,
95+
attn_implementation="eager", # required for output_attentions
96+
)
97+
self._model.eval()
98+
99+
@property
100+
def name(self) -> str:
101+
return "qwen3vl"
102+
103+
def infer(self, image_array: np.ndarray, question: str) -> VLMOutput:
104+
"""Run Qwen3-VL inference.
105+
106+
Args:
107+
image_array: float32 ``(H, W, 3)`` in ``[0, 1]`` β€” converted to
108+
a uint8 PIL image internally.
109+
question: The user question conditioning the model.
110+
111+
Returns:
112+
``VLMOutput`` with the generated answer, a confidence derived
113+
from the first generated token's probability, a square
114+
cross-modal attention grid, and reasoning steps.
115+
"""
116+
import torch
117+
from PIL import Image
118+
119+
self._load()
120+
121+
img_uint8 = (np.clip(image_array, 0, 1) * 255).astype(np.uint8)
122+
pil_image = Image.fromarray(img_uint8)
123+
124+
messages = [
125+
{
126+
"role": "user",
127+
"content": [
128+
{"type": "image", "image": pil_image},
129+
{"type": "text", "text": question},
130+
],
131+
}
132+
]
133+
prompt = self._processor.apply_chat_template(
134+
messages, tokenize=False, add_generation_prompt=True
135+
)
136+
inputs = self._processor(text=[prompt], images=[pil_image], return_tensors="pt")
137+
138+
with torch.no_grad():
139+
generated = self._model.generate(
140+
**inputs,
141+
max_new_tokens=64,
142+
output_scores=True,
143+
return_dict_in_generate=True,
144+
)
145+
forward = self._model(**inputs, output_attentions=True)
146+
147+
answer = self._decode_answer(inputs, generated)
148+
confidence = _first_token_confidence(generated)
149+
attention_map = self._extract_attention(inputs, forward)
150+
151+
return VLMOutput(
152+
answer=answer,
153+
confidence=confidence,
154+
attention_weights=attention_map,
155+
reasoning_steps=[
156+
f"Encoded image + question with Qwen3-VL ({self._model_name})",
157+
f"Generated answer: '{answer[:50]}'",
158+
"Read cross-modal attention from a middle decoder layer",
159+
],
160+
)
161+
162+
def _decode_answer(self, inputs: object, generated: object) -> str:
163+
"""Strip the prompt tokens and decode only the generated continuation."""
164+
input_len = inputs["input_ids"].shape[1] # type: ignore[index]
165+
new_tokens = generated.sequences[0][input_len:] # type: ignore[attr-defined]
166+
return self._processor.batch_decode( # type: ignore[union-attr]
167+
[new_tokens], skip_special_tokens=True
168+
)[0].strip()
169+
170+
def _extract_attention(self, inputs: object, forward: object) -> np.ndarray:
171+
"""Middle-layer, last-token attention over image tokens β†’ square grid."""
172+
attentions = forward.attentions # type: ignore[attr-defined]
173+
layer = _select_middle_layer(len(attentions))
174+
layer_attn = attentions[layer][0] # (heads, seq, seq)
175+
image_token_id = self._model.config.image_token_id # type: ignore[union-attr]
176+
ids = inputs["input_ids"][0] # type: ignore[index]
177+
image_positions = (ids == image_token_id).nonzero(as_tuple=True)[0]
178+
# Last prompt token's head-averaged attention to the image tokens.
179+
last_to_image = layer_attn[:, -1, :].mean(dim=0)[image_positions]
180+
return _attention_row_to_grid(last_to_image.float().cpu().numpy())
181+
182+
183+
def _first_token_confidence(generated: object) -> float:
184+
"""Confidence = softmax probability of the first generated token.
185+
186+
A real signal of the model's certainty in its answer, clamped to
187+
``[0, 1]``. Falls back to ``0.5`` when scores are unavailable.
188+
"""
189+
import torch
190+
191+
scores = getattr(generated, "scores", None)
192+
if not scores:
193+
return 0.5
194+
probs = torch.softmax(scores[0][0], dim=-1)
195+
return float(np.clip(float(probs.max().item()), 0.0, 1.0))
196+
197+
198+
__all__ = [
199+
"Qwen3VLBackend",
200+
]

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Backend registry: maps names to VLMBackend factory functions."""
2+
23
from __future__ import annotations
34

45
from typing import Callable
@@ -36,3 +37,10 @@ def register_defaults() -> None:
3637
register("clip", CLIPBackend)
3738
except ImportError:
3839
pass # transformers not installed
40+
41+
try:
42+
from miru.models.qwen3vl import Qwen3VLBackend
43+
44+
register("qwen3vl", Qwen3VLBackend)
45+
except ImportError:
46+
pass # transformers not installed

β€Žpyproject.tomlβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
]
1717

1818
[project.optional-dependencies]
19-
backends = ["transformers>=4.35.0", "torch>=2.0.0", "Pillow>=9.0.0"]
19+
backends = ["transformers>=4.57.0", "torch>=2.0.0", "Pillow>=9.0.0"]
2020
storage = ["fsspec>=2024.2.0"]
2121
metrics = ["prometheus-client>=0.17.0"]
2222
dev = [

0 commit comments

Comments
Β (0)