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,237 @@
|
|
|
1
|
+
"""Multiple-choice format sensitivity — does the answer follow content or position?
|
|
2
|
+
|
|
3
|
+
LLMs (and VLMs answering MC benchmarks) carry measurable selection bias: some
|
|
4
|
+
models keep the same LETTER when options are reordered, instead of following
|
|
5
|
+
the option CONTENT. This probe rotates the option list, re-asks, and reports
|
|
6
|
+
per-case columns separating the two failure signatures:
|
|
7
|
+
|
|
8
|
+
format_flip_rate — answers track neither content nor a fixed position
|
|
9
|
+
(brittle / guessing);
|
|
10
|
+
positional_bias — answers keep choosing the same letter across rotations
|
|
11
|
+
(position prior overrides perception).
|
|
12
|
+
|
|
13
|
+
Black-box (``requires=GENERATE``); rotations are deterministic, so held-out
|
|
14
|
+
verification can re-run the probe exactly.
|
|
15
|
+
|
|
16
|
+
References:
|
|
17
|
+
- Large Language Models Are Not Robust Multiple Choice Selectors —
|
|
18
|
+
Zheng et al., ICLR 2024 — arXiv:2309.03882
|
|
19
|
+
- Quantifying Language Models' Sensitivity to Spurious Features in Prompt
|
|
20
|
+
Design (FormatSpread) — Sclar et al., ICLR 2024 — arXiv:2310.11324
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import dataclasses
|
|
26
|
+
import re
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
29
|
+
|
|
30
|
+
from evalrx.core.analyzer import Analyzer
|
|
31
|
+
from evalrx.core.capability import Capability
|
|
32
|
+
from evalrx.core.registry import register_analyzer
|
|
33
|
+
from evalrx.core.result import Result
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from evalrx.core.case import CaseBatch, FailureCase
|
|
37
|
+
from evalrx.core.model import Model
|
|
38
|
+
|
|
39
|
+
#: Matches ``A. opt``, ``A) opt`` and ``(A) opt``.
|
|
40
|
+
#:
|
|
41
|
+
#: The leading ``\(?`` is not cosmetic. BBH, AGIEval and several MMLU
|
|
42
|
+
#: redistributions write ``(A) blue ball``, and without it the letter is not at
|
|
43
|
+
#: the start of the line, so the whole block matched nothing and the analyzer
|
|
44
|
+
#: reported ``n_scored=0`` on a purely multiple-choice benchmark — a silent
|
|
45
|
+
#: no-op that still spent a generation budget getting there.
|
|
46
|
+
_OPTION_LINE = re.compile(r"^[ \t]*\(?([A-Z])[.)][ \t]+(.+?)[ \t]*$", re.MULTILINE)
|
|
47
|
+
_PAREN_OPTION = re.compile(r"^[ \t]*\([A-Z]\)")
|
|
48
|
+
_MAX_LETTERS = 26
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_option_block(prompt: str) -> tuple[list[str], Optional[str]]:
|
|
52
|
+
"""The LAST contiguous lettered run ``A. ... B. ...`` in the prompt.
|
|
53
|
+
|
|
54
|
+
A run must start at ``A``, increment by one letter per line, and contain
|
|
55
|
+
only whitespace between consecutive option lines — this keeps few-shot
|
|
56
|
+
examples and restated blocks from being merged into one bogus list.
|
|
57
|
+
Returns ``(options, exact_block_text)`` or ``([], None)``.
|
|
58
|
+
"""
|
|
59
|
+
text = str(prompt or "")
|
|
60
|
+
runs: list[list[re.Match]] = []
|
|
61
|
+
current: list[re.Match] = []
|
|
62
|
+
for match in _OPTION_LINE.finditer(text):
|
|
63
|
+
letter = match.group(1)
|
|
64
|
+
if letter == "A":
|
|
65
|
+
current = [match]
|
|
66
|
+
elif (
|
|
67
|
+
current
|
|
68
|
+
and ord(letter) == ord(current[-1].group(1)) + 1
|
|
69
|
+
and not text[current[-1].end():match.start()].strip()
|
|
70
|
+
):
|
|
71
|
+
current.append(match)
|
|
72
|
+
else:
|
|
73
|
+
current = []
|
|
74
|
+
continue
|
|
75
|
+
if len(current) >= 2:
|
|
76
|
+
runs.append(list(current))
|
|
77
|
+
if not runs:
|
|
78
|
+
return [], None
|
|
79
|
+
best = runs[-1] # final state of the last run
|
|
80
|
+
options = [m.group(2) for m in best]
|
|
81
|
+
return options, text[best[0].start():best[-1].end()]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_options(case: "FailureCase") -> tuple[list[str], Optional[str]]:
|
|
85
|
+
"""Options from ``metadata['options']`` (no block text) or the prompt's lettered block."""
|
|
86
|
+
meta = case.metadata.get("options") if isinstance(case.metadata, dict) else None
|
|
87
|
+
if isinstance(meta, (list, tuple)) and 2 <= len(meta) <= _MAX_LETTERS:
|
|
88
|
+
return [str(o) for o in meta], None
|
|
89
|
+
options, block = extract_option_block(case.inputs.prompt or "")
|
|
90
|
+
if not 2 <= len(options) <= _MAX_LETTERS:
|
|
91
|
+
return [], None
|
|
92
|
+
return options, block
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_options(options: list[str], paren: bool = False) -> str:
|
|
96
|
+
"""Render a rotated block, keeping the prompt's own letter style.
|
|
97
|
+
|
|
98
|
+
``paren`` matters for correctness, not looks: this probe isolates POSITIONAL
|
|
99
|
+
bias by rotating options. Re-rendering a ``(A)`` block as ``A.`` would change
|
|
100
|
+
the delimiter at the same time as the position, so any flip it measured
|
|
101
|
+
could be either cause.
|
|
102
|
+
"""
|
|
103
|
+
if paren:
|
|
104
|
+
return "\n".join(f"({chr(65 + i)}) {opt}" for i, opt in enumerate(options))
|
|
105
|
+
return "\n".join(f"{chr(65 + i)}. {opt}" for i, opt in enumerate(options))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_choice(output: str, n_options: int) -> str:
|
|
109
|
+
"""Chosen letter: a tagged answer first, else the last standalone CAPITAL.
|
|
110
|
+
|
|
111
|
+
The tag pattern is case-insensitive on the raw text; the fallback scans the
|
|
112
|
+
raw (non-uppercased) output so the article 'a' cannot masquerade as option
|
|
113
|
+
A, and a capital immediately followed by a lowercase word (\"A nice ...\")
|
|
114
|
+
is treated as prose, not a choice.
|
|
115
|
+
"""
|
|
116
|
+
n = min(int(n_options), _MAX_LETTERS)
|
|
117
|
+
if n < 1:
|
|
118
|
+
return ""
|
|
119
|
+
last = chr(64 + n) # 'A' + n - 1
|
|
120
|
+
text = str(output or "")
|
|
121
|
+
marked = re.findall(
|
|
122
|
+
# \(? so a model that answers in the prompt's own style -- "Answer: (A)"
|
|
123
|
+
# on a "(A) ..." block -- is read as a tagged answer rather than falling
|
|
124
|
+
# through to the positional fallback below
|
|
125
|
+
rf"(?:answer|choice|option|final)\s*(?:is)?\s*[:=-]?\s*\(?([A-{last}])\b",
|
|
126
|
+
text,
|
|
127
|
+
re.IGNORECASE,
|
|
128
|
+
)
|
|
129
|
+
if marked:
|
|
130
|
+
return marked[-1].upper()
|
|
131
|
+
letters = re.findall(rf"\b([A-{last}])\b(?!\s+[a-z])", text)
|
|
132
|
+
return letters[-1] if letters else ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@register_analyzer("format_sensitivity")
|
|
136
|
+
class FormatSensitivityAnalyzer(Analyzer):
|
|
137
|
+
"""Option-rotation probe: separates positional letter bias from content-tracking on multiple-choice cases.
|
|
138
|
+
|
|
139
|
+
Hyper-parameters:
|
|
140
|
+
n_variants: rotations asked per case (rotation k shifts options by k;
|
|
141
|
+
capped at n_options - 1).
|
|
142
|
+
max_cases: label-stratified cap on probed cases; 0 (the default) = every case.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
name = "format_sensitivity"
|
|
146
|
+
requires = frozenset({Capability.GENERATE})
|
|
147
|
+
applies_to_modalities = frozenset({"text", "image"})
|
|
148
|
+
|
|
149
|
+
def __init__(self, n_variants: int = 3, max_cases: int = 0) -> None:
|
|
150
|
+
super().__init__(n_variants=max(1, int(n_variants)), max_cases=max_cases)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _variant_prompt(
|
|
154
|
+
prompt: str, block: Optional[str], options: list[str], shift: int
|
|
155
|
+
) -> tuple[str, list[str]]:
|
|
156
|
+
rotated = options[shift:] + options[:shift]
|
|
157
|
+
paren = bool(block) and bool(_PAREN_OPTION.match(block))
|
|
158
|
+
rotated_block = render_options(rotated, paren=paren)
|
|
159
|
+
if block:
|
|
160
|
+
# identical restatements of the same block rotate together
|
|
161
|
+
return prompt.replace(block, rotated_block), rotated
|
|
162
|
+
return (
|
|
163
|
+
f"{prompt}\n\nChoices:\n{rotated_block}\nAnswer with the option letter.",
|
|
164
|
+
rotated,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _run(self, model: "Model", cases: "CaseBatch") -> Result:
|
|
168
|
+
per_case: list[dict[str, Any]] = []
|
|
169
|
+
for case in cases.stratified_head(self.max_cases):
|
|
170
|
+
options, block = extract_options(case)
|
|
171
|
+
entry: dict[str, Any] = {"sample_id": case.id, "n_options": len(options)}
|
|
172
|
+
if len(options) < 2:
|
|
173
|
+
entry["skipped"] = "no multiple-choice options found (2..26 required)"
|
|
174
|
+
per_case.append(entry)
|
|
175
|
+
continue
|
|
176
|
+
prompt = case.inputs.prompt or ""
|
|
177
|
+
shifts = list(range(1, min(self.n_variants, len(options) - 1) + 1))
|
|
178
|
+
chosen_contents: list[str] = []
|
|
179
|
+
chosen_letters: list[str] = []
|
|
180
|
+
n_unparsed = 0
|
|
181
|
+
# Identity variant: reuse the recorded baseline answer only when the
|
|
182
|
+
# prompt contains the lettered block the letters decode against;
|
|
183
|
+
# otherwise every variant (identity included) uses OUR rendered
|
|
184
|
+
# block, so all letters map to options the model actually saw.
|
|
185
|
+
if block is not None and case.observed:
|
|
186
|
+
base_out = str(case.observed)
|
|
187
|
+
else:
|
|
188
|
+
identity_prompt, _ = self._variant_prompt(prompt, block, options, 0)
|
|
189
|
+
base_out = str(
|
|
190
|
+
model.generate(dataclasses.replace(case.inputs, prompt=identity_prompt))
|
|
191
|
+
)
|
|
192
|
+
base_letter = parse_choice(base_out, len(options))
|
|
193
|
+
if base_letter:
|
|
194
|
+
chosen_letters.append(base_letter)
|
|
195
|
+
chosen_contents.append(options[ord(base_letter) - 65])
|
|
196
|
+
else:
|
|
197
|
+
n_unparsed += 1
|
|
198
|
+
for shift in shifts:
|
|
199
|
+
variant_prompt, rotated = self._variant_prompt(prompt, block, options, shift)
|
|
200
|
+
out = str(model.generate(dataclasses.replace(case.inputs, prompt=variant_prompt)))
|
|
201
|
+
letter = parse_choice(out, len(rotated))
|
|
202
|
+
if not letter:
|
|
203
|
+
n_unparsed += 1
|
|
204
|
+
continue
|
|
205
|
+
chosen_letters.append(letter)
|
|
206
|
+
chosen_contents.append(rotated[ord(letter) - 65])
|
|
207
|
+
entry["n_variants"] = 1 + len(shifts)
|
|
208
|
+
entry["n_unparsed"] = n_unparsed
|
|
209
|
+
if chosen_contents:
|
|
210
|
+
content_modal = Counter(chosen_contents).most_common(1)[0][1]
|
|
211
|
+
letter_modal = Counter(chosen_letters).most_common(1)[0]
|
|
212
|
+
entry["format_flip_rate"] = round(1.0 - content_modal / len(chosen_contents), 4)
|
|
213
|
+
entry["positional_bias"] = round(letter_modal[1] / len(chosen_letters), 4)
|
|
214
|
+
entry["modal_letter"] = letter_modal[0]
|
|
215
|
+
per_case.append(entry)
|
|
216
|
+
|
|
217
|
+
flips = [c["format_flip_rate"] for c in per_case if "format_flip_rate" in c]
|
|
218
|
+
letters = Counter(c.get("modal_letter") for c in per_case if c.get("modal_letter"))
|
|
219
|
+
findings: dict[str, Any] = {
|
|
220
|
+
"n_cases": len(per_case),
|
|
221
|
+
"n_scored": len(flips),
|
|
222
|
+
"mean_flip_rate": round(sum(flips) / len(flips), 4) if flips else None,
|
|
223
|
+
"modal_letter_histogram": dict(letters),
|
|
224
|
+
"per_case": per_case,
|
|
225
|
+
"_caveat": (
|
|
226
|
+
"Rotations only (not all permutations): a model biased toward "
|
|
227
|
+
"'the option after the correct one' can evade detection. "
|
|
228
|
+
"positional_bias near 1 with format_flip_rate near 1 is the "
|
|
229
|
+
"letter-prior signature; both near 0 is content-tracking. "
|
|
230
|
+
"Letter parsing prefers tagged answers and ignores capitals "
|
|
231
|
+
"followed by a lowercase word, but free-prose answers can still "
|
|
232
|
+
"be misread — n_unparsed tracks the abstentions. INTERVENTIONAL "
|
|
233
|
+
"columns: held-out verification must RE-RUN the rotations, "
|
|
234
|
+
"never reuse exploration-set values."
|
|
235
|
+
),
|
|
236
|
+
}
|
|
237
|
+
return Result(analyzer=self.name, model=repr(model), cases=cases, findings=findings)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""MM-SHAP — modality-contribution metric, generalised to every media slot.
|
|
2
|
+
|
|
3
|
+
Black-box (``LOGPROBS``): Shapley over the input's text tokens plus one player
|
|
4
|
+
per FILLED media slot (image / audio / video), scored by the model's output
|
|
5
|
+
logprob, then aggregated by modality::
|
|
6
|
+
|
|
7
|
+
<slot>_contribution / (text_contribution + Σ media contributions)
|
|
8
|
+
|
|
9
|
+
0 for a slot ⇒ the prediction ignores it; 1 ⇒ it carries the prediction alone.
|
|
10
|
+
Measures *reliance*, not correctness — report it as such.
|
|
11
|
+
|
|
12
|
+
The original metric had exactly one non-text player, the image, and this
|
|
13
|
+
implementation hard-coded that: the value function rebuilt ``Inputs(prompt=...,
|
|
14
|
+
image=...)`` and dropped audio and video on the floor. On an audio benchmark it
|
|
15
|
+
therefore reported ``mm_score`` computed over a slot that was never filled — the
|
|
16
|
+
question "is this model actually listening" was unanswerable by the one analyzer
|
|
17
|
+
whose whole job is answering it. Players are now the slots the case fills, so
|
|
18
|
+
an AVLM gets a per-slot breakdown and ``mm_score`` keeps its published meaning
|
|
19
|
+
(image reliance) as one member of it.
|
|
20
|
+
|
|
21
|
+
Paper: "MM-SHAP: A Performance-agnostic Metric for Measuring Multimodal
|
|
22
|
+
Contributions in Vision and Language Models"
|
|
23
|
+
Parcalabescu & Frank, ACL 2022 — https://arxiv.org/abs/2212.08158
|
|
24
|
+
Code: https://github.com/coastalcph/mm-shap
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
30
|
+
|
|
31
|
+
from evalrx.analyzers.perturbation._shapley import shapley_values
|
|
32
|
+
from evalrx.core.analyzer import Analyzer
|
|
33
|
+
from evalrx.core.capability import Capability
|
|
34
|
+
from evalrx.core.case import MEDIA_SLOTS, Inputs
|
|
35
|
+
from evalrx.core.registry import register_analyzer
|
|
36
|
+
from evalrx.core.result import Result
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from evalrx.core.case import CaseBatch
|
|
40
|
+
from evalrx.core.model import Model
|
|
41
|
+
|
|
42
|
+
#: Player key for a media slot. Namespaced so it can never collide with a word
|
|
43
|
+
#: index, which is a plain int.
|
|
44
|
+
def _slot_player(slot: str) -> str:
|
|
45
|
+
return f"__{slot}__"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Kept for backwards compatibility with callers that imported the old constant.
|
|
49
|
+
_IMAGE = _slot_player("image")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@register_analyzer("mm_shap")
|
|
53
|
+
class MMShapAnalyzer(Analyzer):
|
|
54
|
+
"""Per-modality Shapley contribution + the MM-SHAP media-reliance scores."""
|
|
55
|
+
|
|
56
|
+
name = "mm_shap"
|
|
57
|
+
requires = frozenset({Capability.LOGPROBS})
|
|
58
|
+
applies_to_modalities = frozenset({"text", "image", "audio", "video"})
|
|
59
|
+
signal_docs = {
|
|
60
|
+
'audio_contribution': 'How much the audio drove the answer.',
|
|
61
|
+
'audio_score': ("Audio's share", "The audio's share of everything that drove the answer."),
|
|
62
|
+
'has_audio': 'Whether this case had audio at all.',
|
|
63
|
+
'has_image': 'Whether this case had an image at all.',
|
|
64
|
+
'has_video': 'Whether this case had video at all.',
|
|
65
|
+
'image_contribution': 'How much the image drove the answer.',
|
|
66
|
+
'image_score': ("Image's share", "The image's share of everything that drove the answer."),
|
|
67
|
+
'media_score': ('Reliance on media', 'How much the answer rested on the pictures and sound together, rather than on the question text alone.'),
|
|
68
|
+
'mm_score': ('Reliance on the image', 'How much the answer rested on the image, from 0 (all text) to 1 (all image).'),
|
|
69
|
+
'probed_slots': 'Which modalities were present to weigh.',
|
|
70
|
+
'text_contribution': ('Reliance on the question', "How much the question's own wording drove the answer."),
|
|
71
|
+
'video_contribution': 'How much the video drove the answer.',
|
|
72
|
+
'video_score': ("Video's share", "The video's share of everything that drove the answer."),
|
|
73
|
+
}
|
|
74
|
+
#: Needs at least one media slot filled — with none, every player is a word
|
|
75
|
+
#: and the "modality contribution" it reports is a text-only tautology.
|
|
76
|
+
requires_modalities = frozenset(MEDIA_SLOTS)
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
score_fn: Optional[Callable[[Inputs], float]] = None,
|
|
81
|
+
n_samples: int = 64,
|
|
82
|
+
mask_token: str = "___",
|
|
83
|
+
top_k: int = 5,
|
|
84
|
+
seed: int = 0,
|
|
85
|
+
) -> None:
|
|
86
|
+
super().__init__(score_fn=score_fn, n_samples=n_samples, mask_token=mask_token, top_k=top_k, seed=seed)
|
|
87
|
+
|
|
88
|
+
def _default_scorer(self, model: "Model") -> Callable[[Inputs], float]:
|
|
89
|
+
def score(inputs: Inputs) -> float:
|
|
90
|
+
lps = model.logprobs(inputs)
|
|
91
|
+
return sum(t.logprob for t in lps) / len(lps) if lps else 0.0
|
|
92
|
+
return score
|
|
93
|
+
|
|
94
|
+
def _run(self, model: "Model", cases: "CaseBatch") -> Result:
|
|
95
|
+
case = cases[0]
|
|
96
|
+
words = str(case.inputs.prompt).split()
|
|
97
|
+
filled = [s for s in MEDIA_SLOTS if getattr(case.inputs, s, None) is not None]
|
|
98
|
+
score = self.score_fn or self._default_scorer(model)
|
|
99
|
+
|
|
100
|
+
players: list = list(range(len(words)))
|
|
101
|
+
players += [_slot_player(s) for s in filled]
|
|
102
|
+
|
|
103
|
+
def value(kept: set) -> float:
|
|
104
|
+
masked = " ".join(w if i in kept else self.mask_token for i, w in enumerate(words))
|
|
105
|
+
slots = {
|
|
106
|
+
s: (getattr(case.inputs, s) if _slot_player(s) in kept else None)
|
|
107
|
+
for s in filled
|
|
108
|
+
}
|
|
109
|
+
return score(Inputs(prompt=masked, **slots))
|
|
110
|
+
|
|
111
|
+
shap = shapley_values(players, value, n_samples=self.n_samples, seed=self.seed)
|
|
112
|
+
text_contrib = sum(abs(shap[i]) for i in range(len(words)))
|
|
113
|
+
media_contrib = {s: abs(shap.get(_slot_player(s), 0.0)) for s in filled}
|
|
114
|
+
total = text_contrib + sum(media_contrib.values()) or 1.0
|
|
115
|
+
top_text = sorted(
|
|
116
|
+
({"token": words[i], "shapley": round(shap[i], 4)} for i in range(len(words))),
|
|
117
|
+
key=lambda d: -abs(d["shapley"]),
|
|
118
|
+
)[: self.top_k]
|
|
119
|
+
|
|
120
|
+
findings = {
|
|
121
|
+
# Published MM-SHAP: image reliance. 0.0 when no image is present,
|
|
122
|
+
# which is why the per-slot fields below carry the real answer for an
|
|
123
|
+
# audio or video run rather than this one.
|
|
124
|
+
"mm_score": round(media_contrib.get("image", 0.0) / total, 4),
|
|
125
|
+
"media_score": round(sum(media_contrib.values()) / total, 4),
|
|
126
|
+
"text_contribution": round(text_contrib, 4),
|
|
127
|
+
"probed_slots": filled,
|
|
128
|
+
"top_text_tokens": top_text,
|
|
129
|
+
"_note": "measures modality reliance, not correctness",
|
|
130
|
+
}
|
|
131
|
+
for slot in MEDIA_SLOTS:
|
|
132
|
+
# Every slot gets a key, present or not: a reader must be able to tell
|
|
133
|
+
# "measured as zero" from "never in this case", and an absent key
|
|
134
|
+
# collapses the two into whatever their default lookup returns.
|
|
135
|
+
findings[f"has_{slot}"] = slot in filled
|
|
136
|
+
findings[f"{slot}_contribution"] = (
|
|
137
|
+
round(media_contrib[slot], 4) if slot in filled else None
|
|
138
|
+
)
|
|
139
|
+
findings[f"{slot}_score"] = (
|
|
140
|
+
round(media_contrib[slot] / total, 4) if slot in filled else None
|
|
141
|
+
)
|
|
142
|
+
return Result(
|
|
143
|
+
analyzer=self.name, model=repr(model), cases=cases,
|
|
144
|
+
artifacts={"shapley": shap},
|
|
145
|
+
findings=findings,
|
|
146
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Modality ablation — is the model actually using the modality under test?
|
|
2
|
+
|
|
3
|
+
Black-box (``GENERATE``): for every media slot a case fills, re-ask the same
|
|
4
|
+
question with that slot **ablated**, and compare the answer to the unablated
|
|
5
|
+
baseline. An answer that does not move when the audio is removed or replaced
|
|
6
|
+
was not produced by listening to it.
|
|
7
|
+
|
|
8
|
+
One implementation covers every slot, and that is the point. The question "is
|
|
9
|
+
the image load-bearing" and the question "is the audio load-bearing" are the
|
|
10
|
+
same question asked of a different slot, so a per-modality analyzer would be the
|
|
11
|
+
same code three times, drifting apart — and in practice the audio and video
|
|
12
|
+
copies were simply never written, leaving an ALM run with no way to ask it at
|
|
13
|
+
all. Adding a modality here is adding a member to
|
|
14
|
+
:data:`~evalrx.core.case.MEDIA_SLOTS`.
|
|
15
|
+
|
|
16
|
+
Two ablation modes, because they answer different questions:
|
|
17
|
+
|
|
18
|
+
* ``drop`` (default) — remove the slot entirely. An unchanged answer is strong
|
|
19
|
+
evidence the slot was never read. A *changed* answer proves only sensitivity,
|
|
20
|
+
not correct use: the input shape changed too.
|
|
21
|
+
* ``swap`` — substitute another case's media, keeping the input shape intact and
|
|
22
|
+
breaking only the grounding. Cleaner evidence, and it needs no extra model
|
|
23
|
+
capability, but it requires at least two cases filling the same slot.
|
|
24
|
+
|
|
25
|
+
Reported as reliance, never as correctness. ``ungrounded_rate_<slot>`` is the
|
|
26
|
+
share of probed cases whose answer survived the ablation; ``by_strategy`` carries
|
|
27
|
+
the paired per-case correctness that M2's McNemar reads, which is what makes this
|
|
28
|
+
INTERVENTION-grade rather than one more association.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import dataclasses
|
|
34
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
35
|
+
|
|
36
|
+
from evalrx.analyzers.perturbation.prompt_contrast import _default_score, _norm_answer
|
|
37
|
+
from evalrx.core.analyzer import Analyzer
|
|
38
|
+
from evalrx.core.capability import Capability
|
|
39
|
+
from evalrx.core.case import MEDIA_SLOTS
|
|
40
|
+
from evalrx.core.registry import register_analyzer
|
|
41
|
+
from evalrx.core.result import Result
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from evalrx.core.case import CaseBatch, FailureCase
|
|
45
|
+
from evalrx.core.model import Model
|
|
46
|
+
|
|
47
|
+
BASELINE = "baseline"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@register_analyzer("modality_ablation")
|
|
51
|
+
class ModalityAblationAnalyzer(Analyzer):
|
|
52
|
+
"""Per-slot grounding probe: does ablating a modality move the answer?"""
|
|
53
|
+
|
|
54
|
+
name = "modality_ablation"
|
|
55
|
+
description = (
|
|
56
|
+
"Re-asks each case with one media slot removed or swapped; an unchanged "
|
|
57
|
+
"answer means the model never used that modality."
|
|
58
|
+
)
|
|
59
|
+
requires = frozenset({Capability.GENERATE})
|
|
60
|
+
applies_to_modalities = frozenset({"text", "image", "audio", "video"})
|
|
61
|
+
signal_docs = {
|
|
62
|
+
'ablation_audio_is_swap': "The audio was replaced with another case's rather than removed.",
|
|
63
|
+
'ablation_image_is_swap': "The image was replaced with another case's rather than removed.",
|
|
64
|
+
'ablation_mode_audio': 'How the audio was removed for these cases.',
|
|
65
|
+
'ablation_mode_image': 'How the image was removed for these cases.',
|
|
66
|
+
'ablation_mode_video': 'How the video was removed for these cases.',
|
|
67
|
+
'ablation_video_is_swap': "The video was replaced with another case's rather than removed.",
|
|
68
|
+
'grounded_in_audio': ('Actually listened', 'The answer changed when the audio was taken away — the model was listening.'),
|
|
69
|
+
'grounded_in_image': ('Actually looked', 'The answer changed when the image was taken away — the model was using it.'),
|
|
70
|
+
'grounded_in_video': ('Actually watched', 'The answer changed when the video was taken away — the model was watching.'),
|
|
71
|
+
'mode': "How the modality was removed: dropped entirely, or swapped for another case's.",
|
|
72
|
+
'n_cases_probed': 'How many cases were re-asked with a modality removed.',
|
|
73
|
+
'n_probed_audio': 'How many cases had audio to test.',
|
|
74
|
+
'n_probed_image': 'How many cases had an image to test.',
|
|
75
|
+
'n_probed_video': 'How many cases had video to test.',
|
|
76
|
+
'probed_slots': 'Which modalities were tested this way.',
|
|
77
|
+
'ungrounded_rate_audio': ('Ignored the audio', 'Share of cases whose answer did NOT change when the audio was removed — the model was not listening.'),
|
|
78
|
+
'ungrounded_rate_image': ('Ignored the image', 'Share of cases whose answer did NOT change when the image was removed — the model was not looking.'),
|
|
79
|
+
'ungrounded_rate_video': ('Ignored the video', 'Share of cases whose answer did NOT change when the video was removed — the model was not watching.'),
|
|
80
|
+
}
|
|
81
|
+
#: Needs a filled slot to ablate. Without one there is nothing to remove and
|
|
82
|
+
#: the analyzer would report a grounding rate over zero probes.
|
|
83
|
+
requires_modalities = frozenset(MEDIA_SLOTS)
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
mode: str = "drop",
|
|
88
|
+
slots: "tuple[str, ...] | None" = None,
|
|
89
|
+
score_fn: Optional[Callable[["FailureCase", str], Optional[bool]]] = None,
|
|
90
|
+
max_cases: int = 0,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Args:
|
|
94
|
+
mode: ``"drop"`` (remove the slot) or ``"swap"`` (substitute
|
|
95
|
+
another case's media). ``swap`` falls back to ``drop``
|
|
96
|
+
per-slot when fewer than two cases fill it.
|
|
97
|
+
slots: Restrict to these slots; ``None`` probes every filled one.
|
|
98
|
+
score_fn: ``(case, answer) -> bool | None`` for the paired
|
|
99
|
+
correctness table. Defaults to the prompt-contrast rubric
|
|
100
|
+
scorer; ``None`` verdicts are simply not tabulated.
|
|
101
|
+
max_cases: Cap on cases (cost = 1 + n_filled_slots generations each);
|
|
102
|
+
0 (the default) = every case.
|
|
103
|
+
"""
|
|
104
|
+
if mode not in ("drop", "swap"):
|
|
105
|
+
raise ValueError(f"mode must be 'drop' or 'swap'; got {mode!r}")
|
|
106
|
+
super().__init__(mode=mode, slots=slots, score_fn=score_fn, max_cases=max_cases)
|
|
107
|
+
|
|
108
|
+
# -- helpers -------------------------------------------------------
|
|
109
|
+
def _slots_of(self, case: "FailureCase") -> "list[str]":
|
|
110
|
+
wanted = self.slots or MEDIA_SLOTS
|
|
111
|
+
return [
|
|
112
|
+
s for s in MEDIA_SLOTS
|
|
113
|
+
if s in wanted and getattr(case.inputs, s, None) is not None
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _donor(cases: "list[FailureCase]", slot: str, case: "FailureCase") -> Any:
|
|
118
|
+
"""Another case's media for *slot*, or ``None`` when there is no donor.
|
|
119
|
+
|
|
120
|
+
Deterministic (first non-self filler in batch order) so a rerun of the
|
|
121
|
+
same batch ablates with the same substitute — a random donor would make
|
|
122
|
+
the paired comparison irreproducible.
|
|
123
|
+
"""
|
|
124
|
+
for other in cases:
|
|
125
|
+
if other.id != case.id and getattr(other.inputs, slot, None) is not None:
|
|
126
|
+
return getattr(other.inputs, slot)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def _run(self, model: "Model", cases: "CaseBatch") -> Result:
|
|
130
|
+
score = self.score_fn or _default_score
|
|
131
|
+
selected = cases.stratified_head(self.max_cases)
|
|
132
|
+
selected = [c for c in selected if self._slots_of(c)]
|
|
133
|
+
|
|
134
|
+
by_strategy: dict[str, dict[str, float]] = {BASELINE: {}}
|
|
135
|
+
per_case: list[dict[str, Any]] = []
|
|
136
|
+
# per-slot tallies: probed cases, and those whose answer never moved
|
|
137
|
+
probed = {s: 0 for s in MEDIA_SLOTS}
|
|
138
|
+
ungrounded = {s: 0 for s in MEDIA_SLOTS}
|
|
139
|
+
modes_used: dict[str, str] = {}
|
|
140
|
+
|
|
141
|
+
for case in selected:
|
|
142
|
+
base_answer = str(model.generate(case.inputs))
|
|
143
|
+
base_key = _norm_answer(base_answer)
|
|
144
|
+
base_verdict = score(case, base_answer)
|
|
145
|
+
if base_verdict is not None:
|
|
146
|
+
by_strategy[BASELINE][case.id] = float(bool(base_verdict))
|
|
147
|
+
|
|
148
|
+
row: dict[str, Any] = {"sample_id": case.id}
|
|
149
|
+
for slot in self._slots_of(case):
|
|
150
|
+
donor = self._donor(selected, slot, case) if self.mode == "swap" else None
|
|
151
|
+
# swap with no donor degrades to drop rather than skipping: the
|
|
152
|
+
# weaker evidence is still evidence, and silently probing nothing
|
|
153
|
+
# would look identical to a fully-grounded model.
|
|
154
|
+
effective = "swap" if donor is not None else "drop"
|
|
155
|
+
modes_used[slot] = effective
|
|
156
|
+
ablated = dataclasses.replace(case.inputs, **{slot: donor})
|
|
157
|
+
|
|
158
|
+
answer = str(model.generate(ablated))
|
|
159
|
+
changed = _norm_answer(answer) != base_key
|
|
160
|
+
|
|
161
|
+
probed[slot] += 1
|
|
162
|
+
if not changed:
|
|
163
|
+
ungrounded[slot] += 1
|
|
164
|
+
# Flat scalars only: the per-case harvester reads one level, so a
|
|
165
|
+
# nested {slot: {...}} here would reach no statistic at all.
|
|
166
|
+
row[f"grounded_in_{slot}"] = bool(changed)
|
|
167
|
+
row[f"ablation_{slot}_is_swap"] = effective == "swap"
|
|
168
|
+
|
|
169
|
+
strategy = f"without_{slot}"
|
|
170
|
+
verdict = score(case, answer)
|
|
171
|
+
if verdict is not None:
|
|
172
|
+
by_strategy.setdefault(strategy, {})[case.id] = float(bool(verdict))
|
|
173
|
+
if len(row) > 1:
|
|
174
|
+
per_case.append(row)
|
|
175
|
+
|
|
176
|
+
findings: dict[str, Any] = {
|
|
177
|
+
"n_cases_probed": len(selected),
|
|
178
|
+
"mode": self.mode,
|
|
179
|
+
"probed_slots": sorted(s for s in MEDIA_SLOTS if probed[s]),
|
|
180
|
+
"_note": "measures whether a modality is load-bearing, not correctness",
|
|
181
|
+
"per_case": per_case,
|
|
182
|
+
"by_strategy": by_strategy,
|
|
183
|
+
}
|
|
184
|
+
for slot in MEDIA_SLOTS:
|
|
185
|
+
n = probed[slot]
|
|
186
|
+
# None, not 0.0: "no case filled this slot" and "every case that
|
|
187
|
+
# filled it stayed grounded" are different facts and a reader that
|
|
188
|
+
# cannot separate them will report the wrong one.
|
|
189
|
+
findings[f"n_probed_{slot}"] = n
|
|
190
|
+
findings[f"ungrounded_rate_{slot}"] = round(ungrounded[slot] / n, 4) if n else None
|
|
191
|
+
findings[f"ablation_mode_{slot}"] = modes_used.get(slot)
|
|
192
|
+
return Result(
|
|
193
|
+
analyzer=self.name, model=repr(model), cases=cases,
|
|
194
|
+
findings=findings,
|
|
195
|
+
metadata={"mode": self.mode, "n_selected": len(selected)},
|
|
196
|
+
)
|