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.
Files changed (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. 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