evalrx 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- evalrx-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Relative attention to image tokens — "MLLMs Know Where to Look" (arXiv 2502.17422).
|
|
2
|
+
|
|
3
|
+
For a task-specific prompt, divides its image-patch attention weights by those from
|
|
4
|
+
a generic baseline prompt to reveal which patches are *uniquely* important for the
|
|
5
|
+
question being asked. VLM-only; requires a white-box model whose ``forward()``
|
|
6
|
+
populates ``trace.extras["image_token_mask"]`` and optionally
|
|
7
|
+
``trace.extras["image_spatial_shape"]``.
|
|
8
|
+
|
|
9
|
+
References:
|
|
10
|
+
- Paper: "MLLMs Know Where to Look" — https://arxiv.org/abs/2502.17422
|
|
11
|
+
- Code: https://github.com/saccharomycetes/mllms_know
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from evalrx.core.analyzer import Analyzer
|
|
23
|
+
from evalrx.core.capability import Capability
|
|
24
|
+
from evalrx.core.case import Inputs
|
|
25
|
+
from evalrx.core.registry import register_analyzer
|
|
26
|
+
from evalrx.core.result import Result
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from evalrx.core.case import CaseBatch, FailureCase
|
|
30
|
+
from evalrx.core.model import Model
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _blend_heatmap_on_image(
|
|
36
|
+
image: "Any", heatmap: np.ndarray, *, alpha: float = 0.6, cmap: str = "jet",
|
|
37
|
+
diverging: bool = False,
|
|
38
|
+
) -> "Any":
|
|
39
|
+
"""Alpha-blend a 2-D heatmap onto *image*, weighted by per-pixel intensity.
|
|
40
|
+
|
|
41
|
+
Background (near-zero) regions stay close to the original photo; the
|
|
42
|
+
colour-mapped heatmap shows through more strongly where the signal is
|
|
43
|
+
high (CAM-style overlay) — this is what lets a human or a multimodal
|
|
44
|
+
judge sanity-check whether attention lands on something that makes sense,
|
|
45
|
+
rather than reading an abstract colour grid with no spatial reference.
|
|
46
|
+
|
|
47
|
+
*diverging*: when True (signed maps like a FAIL-minus-PASS difference),
|
|
48
|
+
normalises symmetrically around zero and uses ``cmap``'s full range for
|
|
49
|
+
sign; the alpha weight is driven by ``|heatmap|`` magnitude either way.
|
|
50
|
+
"""
|
|
51
|
+
from matplotlib import colormaps
|
|
52
|
+
from PIL import Image
|
|
53
|
+
|
|
54
|
+
base = image.convert("RGB")
|
|
55
|
+
h = heatmap.astype(np.float64)
|
|
56
|
+
if diverging:
|
|
57
|
+
bound = float(max(abs(h.min()), abs(h.max()))) or 1.0
|
|
58
|
+
norm = (h + bound) / (2 * bound) # -bound..bound -> 0..1
|
|
59
|
+
intensity = np.abs(h) / bound # 0..1 magnitude
|
|
60
|
+
else:
|
|
61
|
+
lo, hi = float(h.min()), float(h.max())
|
|
62
|
+
norm = (h - lo) / (hi - lo + 1e-8)
|
|
63
|
+
intensity = norm
|
|
64
|
+
|
|
65
|
+
def _upsample(mat: np.ndarray) -> np.ndarray:
|
|
66
|
+
img = Image.fromarray((np.clip(mat, 0.0, 1.0) * 255).astype(np.uint8), mode="L")
|
|
67
|
+
return np.asarray(img.resize(base.size, Image.BILINEAR), dtype=np.float64) / 255.0
|
|
68
|
+
|
|
69
|
+
norm_up = _upsample(norm)
|
|
70
|
+
intensity_up = _upsample(intensity)
|
|
71
|
+
|
|
72
|
+
cmap_fn = colormaps[cmap]
|
|
73
|
+
heat_rgb = (cmap_fn(norm_up)[:, :, :3] * 255).astype(np.uint8)
|
|
74
|
+
|
|
75
|
+
base_arr = np.asarray(base, dtype=np.float64)
|
|
76
|
+
a = (intensity_up * alpha)[:, :, None]
|
|
77
|
+
out = base_arr * (1 - a) + heat_rgb.astype(np.float64) * a
|
|
78
|
+
return Image.fromarray(out.astype(np.uint8), mode="RGB")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_image(image: "Any") -> "Any | None":
|
|
82
|
+
"""Decode ``Inputs.image`` to a PIL Image when it's a path/URL the backend
|
|
83
|
+
resolves rather than an already-decoded object (see ``Inputs`` docstring
|
|
84
|
+
in :mod:`evalrx.core.case` — datasets like ``TextVQASizeDataset``
|
|
85
|
+
store a lazy file path, only opened when the model actually forwards).
|
|
86
|
+
|
|
87
|
+
Delegates to ``transformers.image_utils.load_image`` — the exact helper
|
|
88
|
+
``hf_local``'s processor call resolves images through — so the overlay
|
|
89
|
+
sees the same image the model's forward pass did. Returns ``None``
|
|
90
|
+
(never raises) when *image* is already unusable or can't be resolved.
|
|
91
|
+
"""
|
|
92
|
+
if image is None or hasattr(image, "convert"):
|
|
93
|
+
return image
|
|
94
|
+
if not isinstance(image, str):
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
from transformers.image_utils import load_image
|
|
98
|
+
return load_image(image)
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_attention_layer(layer: "int | float", n_layers: int) -> int:
|
|
104
|
+
"""Map a layer spec to an absolute decoder-layer index.
|
|
105
|
+
|
|
106
|
+
- ``int`` (incl. negative): used as an absolute index unchanged.
|
|
107
|
+
- ``float`` in (0, 1): a *fractional depth*, ``round(layer * (n_layers - 1))``.
|
|
108
|
+
|
|
109
|
+
The last decoder layer is dominated by attention sinks (BOS/edge tokens) and
|
|
110
|
+
is **not** spatially grounded; spatial localization over image patches lives
|
|
111
|
+
in the late-middle layers. "MLLMs Know Where to Look" reads layer 22 of 28
|
|
112
|
+
for Qwen2.5-VL-7B (≈0.78 depth). A fractional default keeps this choice
|
|
113
|
+
model-agnostic — it scales with the network's depth instead of hard-coding
|
|
114
|
+
one layer index that only happens to suit a single model.
|
|
115
|
+
"""
|
|
116
|
+
if isinstance(layer, float):
|
|
117
|
+
if not 0.0 < layer < 1.0:
|
|
118
|
+
raise ValueError(f"fractional layer must be in (0, 1), got {layer}")
|
|
119
|
+
return int(round(layer * (n_layers - 1)))
|
|
120
|
+
return int(layer)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def image_token_attention(attn_layer, mask):
|
|
124
|
+
"""Head-averaged attention from the last query position to image patches.
|
|
125
|
+
|
|
126
|
+
The single reduction every image-attention consumer shares: average one
|
|
127
|
+
decoder layer's attention over heads, take the last query row, and keep only
|
|
128
|
+
the image-token columns.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
attn_layer: one layer's attention, shape ``(heads, seq, seq)``.
|
|
132
|
+
mask: boolean image-token mask over the ``seq`` axis.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
1-D tensor of length ``n_image_tokens``.
|
|
136
|
+
"""
|
|
137
|
+
return attn_layer.float().mean(dim=0)[-1][mask]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def attention_heatmap(
|
|
141
|
+
model: "Model", case: "FailureCase", layer: "int | float" = 0.75
|
|
142
|
+
) -> "np.ndarray | None":
|
|
143
|
+
"""One ATTENTION forward → (H, W) image-patch heatmap, or ``None``.
|
|
144
|
+
|
|
145
|
+
Host-side capture used both by analyzers and by the fix module's read-only
|
|
146
|
+
``model_attend()`` bridge (:mod:`evalrx.eval_agent.stages.fix_pipeline`):
|
|
147
|
+
run one attention forward, reduce it with :func:`image_token_attention`, and
|
|
148
|
+
reshape to the backend's ``image_spatial_shape`` (near-square fallback).
|
|
149
|
+
``layer`` is resolved by :func:`resolve_attention_layer` — a float is a
|
|
150
|
+
fractional depth (default 0.75, a spatially-grounded late-middle layer; the
|
|
151
|
+
last layer is sink-dominated and localizes poorly).
|
|
152
|
+
"""
|
|
153
|
+
from evalrx.core.capability import Capability
|
|
154
|
+
|
|
155
|
+
if Capability.ATTENTION not in getattr(model, "capabilities", frozenset()):
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
trace = model.forward(case.inputs, capture={Capability.ATTENTION})
|
|
159
|
+
attns = trace.require(Capability.ATTENTION)
|
|
160
|
+
mask = trace.extras.get("image_token_mask")
|
|
161
|
+
if mask is None:
|
|
162
|
+
return None
|
|
163
|
+
layer_idx = resolve_attention_layer(layer, len(attns))
|
|
164
|
+
heat = image_token_attention(attns[layer_idx], mask).cpu().numpy().astype(np.float64)
|
|
165
|
+
if heat.size == 0:
|
|
166
|
+
return None
|
|
167
|
+
shape = trace.extras.get("image_spatial_shape")
|
|
168
|
+
if shape is not None and int(shape[0]) * int(shape[1]) == heat.size:
|
|
169
|
+
h, w = int(shape[0]), int(shape[1])
|
|
170
|
+
else: # near-square fallback
|
|
171
|
+
h = max(1, int(np.sqrt(heat.size)))
|
|
172
|
+
while heat.size % h:
|
|
173
|
+
h -= 1
|
|
174
|
+
w = heat.size // h
|
|
175
|
+
return heat.reshape(h, w)
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
logger.debug("attention_heatmap failed for %s: %s", getattr(case, "id", "?"), exc)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
#: Spatial-map artifact keys that can be overlaid on a representative case
|
|
182
|
+
#: image; each has a matching ``findings["<key>_case_id"]`` recorded by
|
|
183
|
+
#: ``RelativeAttentionAnalyzer._run`` naming which case to anchor it on.
|
|
184
|
+
_OVERLAYABLE_KEYS = ("spatial_map", "fail_mean_map", "pass_mean_map",
|
|
185
|
+
"diff_map_fail_minus_pass")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class RelativeAttentionResult(Result):
|
|
190
|
+
"""Result of relative attention analysis.
|
|
191
|
+
|
|
192
|
+
Artifacts:
|
|
193
|
+
attn_map — 1-D float32 array of relative weights over image-patch tokens.
|
|
194
|
+
spatial_map — 2-D array (H, W) reshaped from attn_map; same as attn_map
|
|
195
|
+
when the grid shape cannot be inferred.
|
|
196
|
+
fail_mean_map — mean spatial_map over FAIL-labelled cases (when present).
|
|
197
|
+
pass_mean_map — mean spatial_map over PASS-labelled cases (when present).
|
|
198
|
+
diff_map_fail_minus_pass — fail_mean_map - pass_mean_map (when both present).
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def attn_map(self) -> np.ndarray:
|
|
203
|
+
return self.artifacts["attn_map"]
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def spatial_map(self) -> np.ndarray:
|
|
207
|
+
return self.artifacts.get("spatial_map", self.artifacts["attn_map"])
|
|
208
|
+
|
|
209
|
+
def _representative_image(self, key: str) -> "Any | None":
|
|
210
|
+
"""The PIL image of the case backing ``artifacts[key]``, or ``None``.
|
|
211
|
+
|
|
212
|
+
Looked up via ``findings[f"{key}_case_id"]`` — the case whose forward
|
|
213
|
+
pass produced ``spatial_map``, or one representative case from the
|
|
214
|
+
FAIL/PASS group for the mean/diff maps (see :meth:`overlay`).
|
|
215
|
+
"""
|
|
216
|
+
case_id = (self.findings or {}).get(f"{key}_case_id")
|
|
217
|
+
if case_id is None or self.cases is None:
|
|
218
|
+
return None
|
|
219
|
+
for c in self.cases:
|
|
220
|
+
if c.id == case_id:
|
|
221
|
+
return _resolve_image(getattr(c.inputs, "image", None))
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def overlay(self, key: str = "spatial_map", *, alpha: float = 0.6, cmap: str = "jet"):
|
|
225
|
+
"""Alpha-blend ``artifacts[key]`` onto its representative case image.
|
|
226
|
+
|
|
227
|
+
Lets a human (or a multimodal judge) sanity-check whether the
|
|
228
|
+
highlighted patches correspond to something sensible in the actual
|
|
229
|
+
photo, instead of reading an abstract heatmap with no spatial
|
|
230
|
+
reference — see :func:`_blend_heatmap_on_image`.
|
|
231
|
+
|
|
232
|
+
``fail_mean_map`` / ``pass_mean_map`` / ``diff_map_fail_minus_pass``
|
|
233
|
+
are averages that can span *different* source images; the overlay
|
|
234
|
+
anchors them on ONE representative case from that group purely to
|
|
235
|
+
give the heatmap a spatial anchor, not a literal per-pixel
|
|
236
|
+
correspondence — the image is genuinely illustrative there, not the
|
|
237
|
+
exact image each contributing forward pass saw.
|
|
238
|
+
|
|
239
|
+
Returns ``None`` (does not raise) when the map, its representative
|
|
240
|
+
image, or matplotlib is unavailable — overlays are best-effort.
|
|
241
|
+
"""
|
|
242
|
+
mat = self.artifacts.get(key)
|
|
243
|
+
image = self._representative_image(key)
|
|
244
|
+
if mat is None or mat.ndim != 2 or not hasattr(image, "convert"):
|
|
245
|
+
return None
|
|
246
|
+
try:
|
|
247
|
+
return _blend_heatmap_on_image(
|
|
248
|
+
image, mat, alpha=alpha, cmap=cmap, diverging="diff" in key)
|
|
249
|
+
except ImportError:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def save_overlay(self, key: str, path: "Any", **kwargs) -> bool:
|
|
253
|
+
"""Render :meth:`overlay` and save it to *path*; ``True`` on success."""
|
|
254
|
+
img = self.overlay(key, **kwargs)
|
|
255
|
+
if img is None:
|
|
256
|
+
return False
|
|
257
|
+
img.save(path)
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
def image_overlays(self, fig_dir: "Any", stem_prefix: str) -> "list[Any]":
|
|
261
|
+
"""Save every available overlay under ``fig_dir``; return the paths.
|
|
262
|
+
|
|
263
|
+
Hook called by :class:`~evalrx.eval_agent.run_logger_v2.RunLoggerV2`
|
|
264
|
+
(duck-typed — any :class:`Result` subclass may define this) so the
|
|
265
|
+
overlay PNGs land alongside the bare heatmaps and get forwarded to a
|
|
266
|
+
multimodal judge the same way.
|
|
267
|
+
"""
|
|
268
|
+
from pathlib import Path
|
|
269
|
+
|
|
270
|
+
out = []
|
|
271
|
+
for key in _OVERLAYABLE_KEYS:
|
|
272
|
+
if key not in self.artifacts:
|
|
273
|
+
continue
|
|
274
|
+
path = Path(fig_dir) / f"{stem_prefix}_{key}_overlay.png"
|
|
275
|
+
try:
|
|
276
|
+
if self.save_overlay(key, path):
|
|
277
|
+
out.append(path)
|
|
278
|
+
except Exception: # noqa: BLE001 - best-effort visualisation
|
|
279
|
+
pass
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
def plot(self, ax=None, figsize=(6, 6), cmap="hot"):
|
|
283
|
+
"""Heatmap of the relative attention spatial map (requires matplotlib)."""
|
|
284
|
+
try:
|
|
285
|
+
import matplotlib.pyplot as plt
|
|
286
|
+
except ImportError:
|
|
287
|
+
raise ImportError(
|
|
288
|
+
"Visualisation requires matplotlib: pip install evalrx[viz]"
|
|
289
|
+
) from None
|
|
290
|
+
|
|
291
|
+
mat = self.spatial_map
|
|
292
|
+
if mat.ndim == 1:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
"Spatial shape is unknown (image_spatial_shape missing from trace.extras). "
|
|
295
|
+
"The 1-D attn_map is stored in self.artifacts['attn_map']."
|
|
296
|
+
)
|
|
297
|
+
created = ax is None
|
|
298
|
+
if created:
|
|
299
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
300
|
+
im = ax.imshow(mat, cmap=cmap)
|
|
301
|
+
ax.set_title("Relative Attention Map")
|
|
302
|
+
ax.axis("off")
|
|
303
|
+
plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
|
304
|
+
if created:
|
|
305
|
+
plt.tight_layout()
|
|
306
|
+
return fig
|
|
307
|
+
return ax
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@register_analyzer("relative_attention")
|
|
311
|
+
class RelativeAttentionAnalyzer(Analyzer):
|
|
312
|
+
"""Relative attention to image-patch tokens.
|
|
313
|
+
|
|
314
|
+
Runs two forward passes through the same VLM — one with the task-specific
|
|
315
|
+
prompt, one with a generic ``general_prompt`` — then computes the element-wise
|
|
316
|
+
ratio of their last-token attention weights over the image-patch positions.
|
|
317
|
+
Regions with a high ratio are uniquely relevant to the task question.
|
|
318
|
+
|
|
319
|
+
Reference:
|
|
320
|
+
"MLLMs Know Where to Look" (arXiv 2502.17422)
|
|
321
|
+
https://arxiv.org/abs/2502.17422 · https://github.com/saccharomycetes/mllms_know
|
|
322
|
+
|
|
323
|
+
Runs over the **whole batch** (one specific/general forward pair per case)
|
|
324
|
+
and emits per-case focus metrics so the M2 stats layer can correlate
|
|
325
|
+
attention behaviour with PASS/FAIL labels — e.g. "missed-finding cases show
|
|
326
|
+
significantly lower task-specific attention". When cases carry labels, the
|
|
327
|
+
artifacts additionally include the mean spatial maps of the FAIL and PASS
|
|
328
|
+
groups and their difference (``diff_map_fail_minus_pass``) for visual
|
|
329
|
+
comparison.
|
|
330
|
+
|
|
331
|
+
Hyper-parameters:
|
|
332
|
+
general_prompt: Baseline prompt (same image, generic question).
|
|
333
|
+
layer: Decoder layer to read attention from. An ``int`` is an
|
|
334
|
+
absolute index (negative allowed); a ``float`` in (0, 1)
|
|
335
|
+
is a fractional depth (default 0.75 — a late-middle layer
|
|
336
|
+
where image-patch attention is spatially grounded; the
|
|
337
|
+
last layer is sink-dominated, see
|
|
338
|
+
:func:`resolve_attention_layer`).
|
|
339
|
+
top_k: Number of highest-scoring patches to include in findings.
|
|
340
|
+
max_cases: Cap on analysed cases (2 attention-captured forwards each); 0 (the default) = every case.
|
|
341
|
+
|
|
342
|
+
Example::
|
|
343
|
+
|
|
344
|
+
from PIL import Image
|
|
345
|
+
from evalrx.analyzers.attention import RelativeAttentionAnalyzer
|
|
346
|
+
from evalrx.core.case import Inputs
|
|
347
|
+
|
|
348
|
+
analyzer = RelativeAttentionAnalyzer(layer=22)
|
|
349
|
+
case = Inputs(prompt="What color is the car?", image=Image.open("scene.jpg"))
|
|
350
|
+
result = analyzer.run(vlm_model, case)
|
|
351
|
+
result.plot()
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
name = "relative_attention"
|
|
355
|
+
requires = frozenset({Capability.ATTENTION})
|
|
356
|
+
applies_to_modalities = frozenset({"image", "video"})
|
|
357
|
+
# Needs pixels present, not merely declared: an omni model on an audio
|
|
358
|
+
# batch matches on "image" and would map attention over nothing.
|
|
359
|
+
requires_modalities = frozenset({"image", "video"})
|
|
360
|
+
|
|
361
|
+
def __init__(
|
|
362
|
+
self,
|
|
363
|
+
general_prompt: str = "Describe the image.",
|
|
364
|
+
layer: "int | float" = 0.75,
|
|
365
|
+
top_k: int = 5,
|
|
366
|
+
max_cases: int = 0,
|
|
367
|
+
) -> None:
|
|
368
|
+
super().__init__(
|
|
369
|
+
general_prompt=general_prompt, layer=layer, top_k=top_k, max_cases=max_cases
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _run(self, model: "Model", cases: "CaseBatch") -> RelativeAttentionResult:
|
|
373
|
+
per_case: list[dict] = []
|
|
374
|
+
errors: list[str] = []
|
|
375
|
+
first_exc: Exception | None = None
|
|
376
|
+
first: tuple | None = None # (rel_np, spatial_map, n_img, n_layers)
|
|
377
|
+
first_case_id: str | None = None
|
|
378
|
+
maps_fail: list[np.ndarray] = []
|
|
379
|
+
maps_pass: list[np.ndarray] = []
|
|
380
|
+
fail_rep_id: str | None = None
|
|
381
|
+
pass_rep_id: str | None = None
|
|
382
|
+
# Per-case spatial maps (float16) — kept as an artifact (dropped from the
|
|
383
|
+
# light result.json) so the WS4 attention_decoding omnibus can read the
|
|
384
|
+
# full map per case, not just the scalar reductions.
|
|
385
|
+
per_case_maps: dict[str, np.ndarray] = {}
|
|
386
|
+
|
|
387
|
+
for case in cases.stratified_head(self.max_cases):
|
|
388
|
+
try:
|
|
389
|
+
rel_np, spatial_map, n_img, n_layers = self._relative_map(model, case)
|
|
390
|
+
except Exception as exc: # noqa: BLE001 - record and continue the batch
|
|
391
|
+
errors.append(f"{case.id}: {exc}")
|
|
392
|
+
if first_exc is None:
|
|
393
|
+
first_exc = exc
|
|
394
|
+
continue
|
|
395
|
+
if first is None:
|
|
396
|
+
first = (rel_np, spatial_map, n_img, n_layers)
|
|
397
|
+
first_case_id = case.id
|
|
398
|
+
|
|
399
|
+
k = min(self.top_k, rel_np.size)
|
|
400
|
+
topk_share = float(np.sort(rel_np)[::-1][:k].sum() / (rel_np.sum() + 1e-8))
|
|
401
|
+
entry = {
|
|
402
|
+
"id": case.id,
|
|
403
|
+
"max_relative_weight": round(float(rel_np.max()), 4),
|
|
404
|
+
"mean_relative_weight": round(float(rel_np.mean()), 4),
|
|
405
|
+
"focus_share": round(topk_share, 4),
|
|
406
|
+
}
|
|
407
|
+
entry.update(_attention_features(rel_np, spatial_map))
|
|
408
|
+
per_case.append(entry)
|
|
409
|
+
per_case_maps[case.id] = spatial_map.astype(np.float16)
|
|
410
|
+
|
|
411
|
+
label = getattr(getattr(case, "label", None), "value", None)
|
|
412
|
+
if label == "fail":
|
|
413
|
+
maps_fail.append(spatial_map)
|
|
414
|
+
if fail_rep_id is None:
|
|
415
|
+
fail_rep_id = case.id
|
|
416
|
+
elif label == "pass":
|
|
417
|
+
maps_pass.append(spatial_map)
|
|
418
|
+
if pass_rep_id is None:
|
|
419
|
+
pass_rep_id = case.id
|
|
420
|
+
|
|
421
|
+
if first is None:
|
|
422
|
+
# Whole batch failed — preserve the original single-case semantics
|
|
423
|
+
# (probe_agent catches this and warns with the actionable message).
|
|
424
|
+
raise first_exc if first_exc is not None else ValueError("empty case batch")
|
|
425
|
+
|
|
426
|
+
rel_np, spatial_map, n_img, n_layers = first
|
|
427
|
+
topk_idx = np.argsort(rel_np)[::-1][: self.top_k]
|
|
428
|
+
top_patches = [
|
|
429
|
+
{"patch_idx": int(i), "relative_weight": round(float(rel_np[i]), 4)}
|
|
430
|
+
for i in topk_idx
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
artifacts: dict = {"attn_map": rel_np, "spatial_map": spatial_map}
|
|
434
|
+
if per_case_maps:
|
|
435
|
+
artifacts["per_case_maps"] = per_case_maps
|
|
436
|
+
fail_mean = _group_mean_map(maps_fail)
|
|
437
|
+
pass_mean = _group_mean_map(maps_pass)
|
|
438
|
+
if fail_mean is not None:
|
|
439
|
+
artifacts["fail_mean_map"] = fail_mean
|
|
440
|
+
if pass_mean is not None:
|
|
441
|
+
artifacts["pass_mean_map"] = pass_mean
|
|
442
|
+
if (
|
|
443
|
+
fail_mean is not None and pass_mean is not None
|
|
444
|
+
and fail_mean.shape == pass_mean.shape
|
|
445
|
+
):
|
|
446
|
+
artifacts["diff_map_fail_minus_pass"] = fail_mean - pass_mean
|
|
447
|
+
|
|
448
|
+
maxes = [e["max_relative_weight"] for e in per_case]
|
|
449
|
+
findings: dict = {
|
|
450
|
+
"n_image_tokens": n_img,
|
|
451
|
+
"n_layers": n_layers,
|
|
452
|
+
"layer_used": resolve_attention_layer(self.layer, n_layers),
|
|
453
|
+
"map_shape": list(spatial_map.shape),
|
|
454
|
+
"top_patches": top_patches,
|
|
455
|
+
"n_cases_analyzed": len(per_case),
|
|
456
|
+
"n_errors": len(errors),
|
|
457
|
+
"mean_max_relative_weight": round(float(np.mean(maxes)), 4),
|
|
458
|
+
"per_case": per_case,
|
|
459
|
+
# Anchors RelativeAttentionResult.overlay(): which case's image each
|
|
460
|
+
# spatial-map-shaped artifact should be alpha-blended onto.
|
|
461
|
+
"spatial_map_case_id": first_case_id,
|
|
462
|
+
}
|
|
463
|
+
if "fail_mean_map" in artifacts:
|
|
464
|
+
findings["fail_mean_map_case_id"] = fail_rep_id
|
|
465
|
+
if "pass_mean_map" in artifacts:
|
|
466
|
+
findings["pass_mean_map_case_id"] = pass_rep_id
|
|
467
|
+
if "diff_map_fail_minus_pass" in artifacts:
|
|
468
|
+
findings["diff_map_fail_minus_pass_case_id"] = fail_rep_id
|
|
469
|
+
if maps_fail and maps_pass:
|
|
470
|
+
label_by_id = {
|
|
471
|
+
c.id: getattr(getattr(c, "label", None), "value", None) for c in cases
|
|
472
|
+
}
|
|
473
|
+
fail_maxes = [e["max_relative_weight"] for e in per_case
|
|
474
|
+
if label_by_id.get(e["id"]) == "fail"]
|
|
475
|
+
pass_maxes = [e["max_relative_weight"] for e in per_case
|
|
476
|
+
if label_by_id.get(e["id"]) == "pass"]
|
|
477
|
+
if fail_maxes and pass_maxes:
|
|
478
|
+
findings["fail_mean_max_relative_weight"] = round(float(np.mean(fail_maxes)), 4)
|
|
479
|
+
findings["pass_mean_max_relative_weight"] = round(float(np.mean(pass_maxes)), 4)
|
|
480
|
+
|
|
481
|
+
result = RelativeAttentionResult(
|
|
482
|
+
analyzer=self.name,
|
|
483
|
+
model=repr(model),
|
|
484
|
+
cases=cases,
|
|
485
|
+
artifacts=artifacts,
|
|
486
|
+
)
|
|
487
|
+
result.findings = findings
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
def _relative_map(self, model: "Model", case) -> "tuple[np.ndarray, np.ndarray, int, int]":
|
|
491
|
+
"""One specific/general forward pair → (rel_map, spatial_map, n_img, n_layers)."""
|
|
492
|
+
import torch # noqa: F401 - tensor ops below
|
|
493
|
+
|
|
494
|
+
# Forward pass 1: task-specific prompt
|
|
495
|
+
specific_trace = model.forward(case.inputs, capture={Capability.ATTENTION})
|
|
496
|
+
|
|
497
|
+
# Forward pass 2: generic baseline — same image/video, generic question
|
|
498
|
+
general_inputs = Inputs(
|
|
499
|
+
prompt=self.general_prompt,
|
|
500
|
+
image=case.inputs.image,
|
|
501
|
+
video=getattr(case.inputs, "video", None),
|
|
502
|
+
)
|
|
503
|
+
general_trace = model.forward(general_inputs, capture={Capability.ATTENTION})
|
|
504
|
+
|
|
505
|
+
# Image-token masks — each trace reports its own positions because the
|
|
506
|
+
# two prompts have different sequence lengths.
|
|
507
|
+
specific_mask = specific_trace.extras.get("image_token_mask")
|
|
508
|
+
general_mask = general_trace.extras.get("image_token_mask")
|
|
509
|
+
|
|
510
|
+
if specific_mask is None or general_mask is None:
|
|
511
|
+
raise ValueError(
|
|
512
|
+
"trace.extras['image_token_mask'] is missing. "
|
|
513
|
+
"RelativeAttentionAnalyzer requires a VLM model whose forward() "
|
|
514
|
+
"populates image token positions (hf_local with is_vlm=True)."
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
n_img = int(specific_mask.sum())
|
|
518
|
+
if n_img == 0:
|
|
519
|
+
raise ValueError("image_token_mask contains no True entries — no image tokens found.")
|
|
520
|
+
if int(general_mask.sum()) != n_img:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
f"Image token count differs between specific ({n_img}) and "
|
|
523
|
+
f"general ({int(general_mask.sum())}) forward passes. "
|
|
524
|
+
"Both prompts must use the same image."
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
n_layers = len(specific_trace.require(Capability.ATTENTION))
|
|
528
|
+
layer_idx = resolve_attention_layer(self.layer, n_layers)
|
|
529
|
+
|
|
530
|
+
def _img_attn(trace, mask):
|
|
531
|
+
"""Head-averaged attention from the last query position to image patches."""
|
|
532
|
+
attns = trace.require(Capability.ATTENTION)
|
|
533
|
+
return image_token_attention(attns[layer_idx], mask) # (n_img_tokens,)
|
|
534
|
+
|
|
535
|
+
specific_attn = _img_attn(specific_trace, specific_mask)
|
|
536
|
+
general_attn = _img_attn(general_trace, general_mask)
|
|
537
|
+
|
|
538
|
+
# Relative attention: ratio highlighting task-relevant patches
|
|
539
|
+
rel = specific_attn / (general_attn + 1e-8)
|
|
540
|
+
rel_np = rel.cpu().float().numpy()
|
|
541
|
+
|
|
542
|
+
# Spatial reshape using the grid shape stored by the VLM backend
|
|
543
|
+
spatial_shape = specific_trace.extras.get("image_spatial_shape")
|
|
544
|
+
if spatial_shape is not None:
|
|
545
|
+
h, w = spatial_shape
|
|
546
|
+
spatial_map = rel_np.reshape(h, w) if h * w == n_img else rel_np
|
|
547
|
+
else:
|
|
548
|
+
side = int(n_img ** 0.5)
|
|
549
|
+
spatial_map = rel_np.reshape(side, side) if side * side == n_img else rel_np
|
|
550
|
+
|
|
551
|
+
return rel_np, spatial_map, n_img, n_layers
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _attention_features(rel_np: np.ndarray, spatial_map: np.ndarray) -> dict:
|
|
555
|
+
"""Richer per-case scalars derived from one case's relative-attention map.
|
|
556
|
+
|
|
557
|
+
Beyond the raw max/mean weight, these capture the SHAPE of attention so M2
|
|
558
|
+
can test the mechanisms M3 reasons about:
|
|
559
|
+
- ``attention_entropy`` normalized Shannon entropy of the weight
|
|
560
|
+
distribution (1 = diffuse/smeared, 0 = a single spike) → the
|
|
561
|
+
"diffuse vs. concentrated" regime split.
|
|
562
|
+
- ``top1_share`` fraction of total weight on the single peak patch
|
|
563
|
+
(single-patch dominance).
|
|
564
|
+
- ``center_offset`` distance of the attention centre-of-mass from the
|
|
565
|
+
image centre, normalized to [0, 1] (peripheral vs. central attention).
|
|
566
|
+
- ``edge_mass`` fraction of weight on the border ring of patches
|
|
567
|
+
(a positional-sink proxy — sinks tend to sit at edges/corners).
|
|
568
|
+
All are continuous in [0, 1] (or ≥0), so they enter M2 as genuine
|
|
569
|
+
discriminators, never as a label-reconstructing flag.
|
|
570
|
+
"""
|
|
571
|
+
w = rel_np.astype(np.float64).ravel()
|
|
572
|
+
total = float(w.sum()) + 1e-12
|
|
573
|
+
p = w / total
|
|
574
|
+
nz = p[p > 0]
|
|
575
|
+
ent = float(-(nz * np.log(nz)).sum())
|
|
576
|
+
ent_norm = ent / (np.log(max(w.size, 2))) if w.size > 1 else 0.0
|
|
577
|
+
feats: dict = {
|
|
578
|
+
"attention_entropy": round(float(ent_norm), 4),
|
|
579
|
+
"top1_share": round(float(w.max() / total), 4),
|
|
580
|
+
}
|
|
581
|
+
if spatial_map.ndim == 2 and spatial_map.size > 1:
|
|
582
|
+
m = spatial_map.astype(np.float64)
|
|
583
|
+
msum = float(m.sum()) + 1e-12
|
|
584
|
+
h, w2 = m.shape
|
|
585
|
+
rows = np.arange(h)[:, None]
|
|
586
|
+
cols = np.arange(w2)[None, :]
|
|
587
|
+
com_r = float((m * rows).sum() / msum)
|
|
588
|
+
com_c = float((m * cols).sum() / msum)
|
|
589
|
+
cr, cc = (h - 1) / 2.0, (w2 - 1) / 2.0
|
|
590
|
+
off = np.hypot((com_r - cr) / (cr + 1e-9), (com_c - cc) / (cc + 1e-9)) / np.sqrt(2.0)
|
|
591
|
+
feats["center_offset"] = round(float(min(off, 1.0)), 4)
|
|
592
|
+
border = np.zeros((h, w2), dtype=bool)
|
|
593
|
+
border[0, :] = border[-1, :] = border[:, 0] = border[:, -1] = True
|
|
594
|
+
feats["edge_mass"] = round(float(m[border].sum() / msum), 4)
|
|
595
|
+
return feats
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _group_mean_map(maps: "list[np.ndarray]") -> "np.ndarray | None":
|
|
599
|
+
"""Mean of the spatial maps sharing the most common shape (None when empty).
|
|
600
|
+
|
|
601
|
+
Different source images can produce different patch grids; averaging is only
|
|
602
|
+
meaningful within one grid shape, so minority shapes are dropped.
|
|
603
|
+
"""
|
|
604
|
+
if not maps:
|
|
605
|
+
return None
|
|
606
|
+
from collections import Counter
|
|
607
|
+
|
|
608
|
+
shape = Counter(m.shape for m in maps).most_common(1)[0][0]
|
|
609
|
+
same = [m for m in maps if m.shape == shape]
|
|
610
|
+
return np.mean(np.stack(same), axis=0)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Attention rollout (Abnar & Zuidema, 2020).
|
|
2
|
+
|
|
3
|
+
Composes per-layer attention (head-averaged, residual-augmented, row-normalised)
|
|
4
|
+
across the stack to estimate how much each input token influences the final
|
|
5
|
+
position. Operates on a ``Trace`` (``requires=ATTENTION``).
|
|
6
|
+
|
|
7
|
+
Caveat: rollout is a heuristic over raw attention — see "Attention is not
|
|
8
|
+
Explanation" (Jain & Wallace) before over-claiming causality.
|
|
9
|
+
|
|
10
|
+
References:
|
|
11
|
+
- Quantifying Attention Flow in Transformers (attention rollout)
|
|
12
|
+
Abnar & Zuidema, ACL 2020 — arXiv:2005.00928
|
|
13
|
+
- Caveat — Attention is not Explanation, Jain & Wallace, NAACL 2019 — arXiv:1902.10186
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from evalrx.core.analyzer import Analyzer
|
|
22
|
+
from evalrx.core.capability import Capability
|
|
23
|
+
from evalrx.core.registry import register_analyzer
|
|
24
|
+
from evalrx.core.result import Result
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from evalrx.core.case import CaseBatch
|
|
28
|
+
from evalrx.core.model import Model
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RolloutResult(Result):
|
|
33
|
+
tokens: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def rollout(self):
|
|
37
|
+
return self.artifacts["rollout"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register_analyzer("attention_rollout")
|
|
41
|
+
class AttentionRolloutAnalyzer(Analyzer):
|
|
42
|
+
"""Attention-rollout influence of each token on the final position."""
|
|
43
|
+
|
|
44
|
+
name = "attention_rollout"
|
|
45
|
+
requires = frozenset({Capability.ATTENTION})
|
|
46
|
+
applies_to_modalities = frozenset({"text", "image"})
|
|
47
|
+
|
|
48
|
+
def __init__(self, top_k: int = 5) -> None:
|
|
49
|
+
super().__init__(top_k=top_k)
|
|
50
|
+
|
|
51
|
+
def _run(self, model: "Model", cases: "CaseBatch") -> RolloutResult:
|
|
52
|
+
import torch
|
|
53
|
+
|
|
54
|
+
case = cases[0]
|
|
55
|
+
trace = model.forward(case.inputs, capture={Capability.ATTENTION})
|
|
56
|
+
attns = trace.require(Capability.ATTENTION) # list of (heads, seq, seq)
|
|
57
|
+
seq = attns[0].shape[-1]
|
|
58
|
+
eye = torch.eye(seq)
|
|
59
|
+
rollout = eye.clone()
|
|
60
|
+
for a in attns:
|
|
61
|
+
A = a.float().mean(0) + eye # head-average + residual
|
|
62
|
+
A = A / A.sum(dim=-1, keepdim=True) # row-normalise
|
|
63
|
+
rollout = A @ rollout
|
|
64
|
+
last = rollout[-1]
|
|
65
|
+
k = min(self.top_k, seq)
|
|
66
|
+
topk = torch.topk(last, k)
|
|
67
|
+
top = [{"token": trace.tokens[i], "weight": round(float(v), 4)} for v, i in zip(topk.values, topk.indices)]
|
|
68
|
+
result = RolloutResult(
|
|
69
|
+
analyzer=self.name, model=repr(model), cases=cases,
|
|
70
|
+
tokens=trace.tokens, artifacts={"rollout": rollout},
|
|
71
|
+
)
|
|
72
|
+
result.findings = {"seq_len": seq, "n_layers": len(attns), "top_rollout_tokens": top}
|
|
73
|
+
return result
|