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,695 @@
|
|
|
1
|
+
"""VLM (image + text) QA loaders → CaseBatch.
|
|
2
|
+
|
|
3
|
+
``VLMQADataset`` is the generic image+text QA loader. ``TextVQASizeDataset``
|
|
4
|
+
wraps TextVQA-style records with answer bounding boxes into the small/medium/
|
|
5
|
+
large partitions used by Zhang et al. (ICLR 2025, arXiv:2502.17422).
|
|
6
|
+
``Spatial457Dataset`` wraps the **RyanWW/Spatial457** benchmark (6D
|
|
7
|
+
spatial-reasoning VQA) via the HuggingFace ``datasets`` library (optional dep —
|
|
8
|
+
``pip install evalrx[data]``).
|
|
9
|
+
|
|
10
|
+
The image goes into ``Inputs.image`` (kept OUT of metadata so heavy PIL objects
|
|
11
|
+
aren't duplicated); the gold answer is ``expected``; cases are tagged ``vlm_qa``.
|
|
12
|
+
|
|
13
|
+
Spatial457:
|
|
14
|
+
Paper: "Spatial457: A Diagnostic Benchmark for 6D Spatial Reasoning of Large
|
|
15
|
+
Multimodal Models" — Wang et al., CVPR 2025 — arXiv:2502.08636
|
|
16
|
+
Data: https://huggingface.co/datasets/RyanWW/Spatial457
|
|
17
|
+
Code: https://github.com/XingruiWang/Spatial457
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from evalrx.core.case import CaseBatch, FailureCase, Inputs, Provenance, Source
|
|
28
|
+
from evalrx.datasets.base import Dataset, read_jsonl
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _vlm_cases(
|
|
32
|
+
records: Iterable[dict],
|
|
33
|
+
*,
|
|
34
|
+
prompt_key: str,
|
|
35
|
+
answer_key: str,
|
|
36
|
+
image_key: str,
|
|
37
|
+
tags: set[str],
|
|
38
|
+
meta_keys: tuple[str, ...] = (),
|
|
39
|
+
base_meta: dict | None = None,
|
|
40
|
+
) -> CaseBatch:
|
|
41
|
+
"""Build image+text cases — image -> Inputs.image (NOT metadata); selected keys -> metadata."""
|
|
42
|
+
out = CaseBatch()
|
|
43
|
+
for rec in records:
|
|
44
|
+
meta = dict(base_meta or {})
|
|
45
|
+
for k in meta_keys:
|
|
46
|
+
if k in rec:
|
|
47
|
+
meta[k] = rec[k]
|
|
48
|
+
out.append(FailureCase(
|
|
49
|
+
inputs=Inputs(prompt=str(rec.get(prompt_key, "")), image=rec.get(image_key)),
|
|
50
|
+
expected=rec.get(answer_key),
|
|
51
|
+
tags=set(tags),
|
|
52
|
+
provenance=Provenance(source=Source.DATASET),
|
|
53
|
+
metadata=meta,
|
|
54
|
+
))
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_VLM_SAMPLE = [
|
|
59
|
+
{"question": "What is shown in the image?", "answer": "a placeholder", "image": None, "note": "attach a real image"},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VLMQADataset(Dataset):
|
|
64
|
+
"""Generic image+text question-answering loader."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
records: list[dict] | None = None,
|
|
69
|
+
path: str | None = None,
|
|
70
|
+
*,
|
|
71
|
+
prompt_key: str = "question",
|
|
72
|
+
answer_key: str = "answer",
|
|
73
|
+
image_key: str = "image",
|
|
74
|
+
meta_keys: tuple[str, ...] = (),
|
|
75
|
+
) -> None:
|
|
76
|
+
self._records = records
|
|
77
|
+
self._path = path
|
|
78
|
+
self._prompt_key, self._answer_key, self._image_key = prompt_key, answer_key, image_key
|
|
79
|
+
self._meta_keys = meta_keys
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_records(cls, records: list[dict], **kw) -> "VLMQADataset":
|
|
83
|
+
return cls(records=records, **kw)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_jsonl(cls, path: str, **kw) -> "VLMQADataset":
|
|
87
|
+
return cls(path=path, **kw)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def sample(cls) -> "VLMQADataset":
|
|
91
|
+
return cls(records=_VLM_SAMPLE, meta_keys=("note",))
|
|
92
|
+
|
|
93
|
+
def load(self) -> CaseBatch:
|
|
94
|
+
records = self._records if self._records is not None else (
|
|
95
|
+
read_jsonl(self._path) if self._path else _VLM_SAMPLE
|
|
96
|
+
)
|
|
97
|
+
return _vlm_cases(records, prompt_key=self._prompt_key, answer_key=self._answer_key,
|
|
98
|
+
image_key=self._image_key, tags={"vlm_qa"}, meta_keys=self._meta_keys)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# TextVQA size-sensitivity split from arXiv:2502.17422:
|
|
102
|
+
# S = answer_bbox_area / image_area; small < 0.005, medium [0.005, 0.05), large >= 0.05.
|
|
103
|
+
_TEXTVQA_SIZE_THRESHOLDS = {
|
|
104
|
+
"small": (None, 0.005),
|
|
105
|
+
"medium": (0.005, 0.05),
|
|
106
|
+
"large": (0.05, None),
|
|
107
|
+
}
|
|
108
|
+
_TEXTVQA_BBOX_KEYS = (
|
|
109
|
+
"answer_bbox",
|
|
110
|
+
"answer_bboxes",
|
|
111
|
+
"answer_box",
|
|
112
|
+
"answer_boxes",
|
|
113
|
+
"bbox",
|
|
114
|
+
"bboxes",
|
|
115
|
+
"bounding_box",
|
|
116
|
+
"bounding_boxes",
|
|
117
|
+
"ocr_bbox",
|
|
118
|
+
"ocr_bboxes",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_json_or_jsonl(path: str | Path) -> list[dict]:
|
|
123
|
+
p = Path(path)
|
|
124
|
+
if p.suffix.lower() == ".jsonl":
|
|
125
|
+
return read_jsonl(p)
|
|
126
|
+
with open(p, encoding="utf-8") as f:
|
|
127
|
+
data = json.load(f)
|
|
128
|
+
if isinstance(data, dict):
|
|
129
|
+
for key in ("data", "annotations", "questions", "records"):
|
|
130
|
+
if isinstance(data.get(key), list):
|
|
131
|
+
return [r for r in data[key] if isinstance(r, dict)]
|
|
132
|
+
if isinstance(data, list):
|
|
133
|
+
return [r for r in data if isinstance(r, dict)]
|
|
134
|
+
raise ValueError(f"unsupported TextVQA annotation shape in {p}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _first_present(rec: dict, keys: tuple[str, ...]) -> Any:
|
|
138
|
+
for key in keys:
|
|
139
|
+
if key in rec and rec[key] not in (None, ""):
|
|
140
|
+
return rec[key]
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _textvqa_answers(rec: dict) -> list[str]:
|
|
145
|
+
raw = _first_present(rec, ("answers", "valid_answers", "answer", "gold_answer"))
|
|
146
|
+
if raw is None:
|
|
147
|
+
return []
|
|
148
|
+
if isinstance(raw, str):
|
|
149
|
+
return [raw]
|
|
150
|
+
if isinstance(raw, dict):
|
|
151
|
+
value = str(_first_present(raw, ("answer", "text", "word", "value")) or "").strip()
|
|
152
|
+
return [value] if value else []
|
|
153
|
+
out: list[str] = []
|
|
154
|
+
if isinstance(raw, Iterable):
|
|
155
|
+
for item in raw:
|
|
156
|
+
if isinstance(item, str):
|
|
157
|
+
value = item
|
|
158
|
+
elif isinstance(item, dict):
|
|
159
|
+
value = str(_first_present(item, ("answer", "text", "word", "value")) or "")
|
|
160
|
+
else:
|
|
161
|
+
value = str(item)
|
|
162
|
+
value = value.strip()
|
|
163
|
+
if value and value not in out:
|
|
164
|
+
out.append(value)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _textvqa_rubric(answers: list[str]) -> str | dict:
|
|
169
|
+
if not answers:
|
|
170
|
+
return ""
|
|
171
|
+
if len(answers) == 1:
|
|
172
|
+
return answers[0]
|
|
173
|
+
return {"any_of": answers}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _image_value(rec: dict, image_dir: str | Path | None, image_key: str) -> Any:
|
|
177
|
+
image = rec.get(image_key)
|
|
178
|
+
if image is None:
|
|
179
|
+
image = _first_present(rec, ("image_path", "image_file", "file_name", "filename"))
|
|
180
|
+
if isinstance(image, str) and image_dir and not image.startswith(("http://", "https://")):
|
|
181
|
+
p = Path(image)
|
|
182
|
+
if not p.is_absolute():
|
|
183
|
+
image = str(Path(image_dir) / p)
|
|
184
|
+
return image
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _image_size(rec: dict, image: Any) -> tuple[int, int] | None:
|
|
188
|
+
width = _first_present(rec, ("image_width", "width", "w"))
|
|
189
|
+
height = _first_present(rec, ("image_height", "height", "h"))
|
|
190
|
+
if width and height:
|
|
191
|
+
try:
|
|
192
|
+
return int(width), int(height)
|
|
193
|
+
except (TypeError, ValueError):
|
|
194
|
+
pass
|
|
195
|
+
try:
|
|
196
|
+
from PIL import Image
|
|
197
|
+
|
|
198
|
+
if isinstance(image, Image.Image):
|
|
199
|
+
return image.size
|
|
200
|
+
if isinstance(image, str) and image and Path(image).exists():
|
|
201
|
+
with Image.open(image) as img:
|
|
202
|
+
return img.size
|
|
203
|
+
except Exception:
|
|
204
|
+
return None
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_box_like(value: Any) -> bool:
|
|
209
|
+
if isinstance(value, dict):
|
|
210
|
+
keys = set(value)
|
|
211
|
+
return bool({"x", "left", "x1"} & keys) and bool({"y", "top", "y1"} & keys)
|
|
212
|
+
if isinstance(value, (list, tuple)) and len(value) == 4:
|
|
213
|
+
try:
|
|
214
|
+
[float(v) for v in value]
|
|
215
|
+
except (TypeError, ValueError):
|
|
216
|
+
return False
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _flatten_boxes(value: Any) -> list[Any]:
|
|
222
|
+
if value is None:
|
|
223
|
+
return []
|
|
224
|
+
if _is_box_like(value):
|
|
225
|
+
return [value]
|
|
226
|
+
if isinstance(value, dict):
|
|
227
|
+
nested = _first_present(value, _TEXTVQA_BBOX_KEYS)
|
|
228
|
+
return _flatten_boxes(nested)
|
|
229
|
+
if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
|
|
230
|
+
out: list[Any] = []
|
|
231
|
+
for item in value:
|
|
232
|
+
out.extend(_flatten_boxes(item))
|
|
233
|
+
return out
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _bbox_from_ocr_tokens(rec: dict, answers: list[str]) -> list[Any]:
|
|
238
|
+
tokens = _first_present(rec, ("ocr_info", "ocr_tokens", "ocr", "ocr_words"))
|
|
239
|
+
if not isinstance(tokens, Iterable) or isinstance(tokens, (str, bytes)):
|
|
240
|
+
return []
|
|
241
|
+
answer_terms = {a.strip().lower() for a in answers if str(a).strip()}
|
|
242
|
+
answer_words = {
|
|
243
|
+
part
|
|
244
|
+
for answer in answer_terms
|
|
245
|
+
for part in str(answer).replace("/", " ").replace("-", " ").split()
|
|
246
|
+
if part
|
|
247
|
+
}
|
|
248
|
+
out: list[Any] = []
|
|
249
|
+
for token in tokens:
|
|
250
|
+
if not isinstance(token, dict):
|
|
251
|
+
continue
|
|
252
|
+
text = str(_first_present(token, ("text", "word", "ocr_text", "value")) or "").strip().lower()
|
|
253
|
+
if answer_terms and text not in answer_terms and text not in answer_words:
|
|
254
|
+
continue
|
|
255
|
+
out.extend(_flatten_boxes(_first_present(token, _TEXTVQA_BBOX_KEYS)))
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _box_to_norm_xyxy(
|
|
260
|
+
box: Any,
|
|
261
|
+
*,
|
|
262
|
+
image_size: tuple[int, int],
|
|
263
|
+
bbox_format: str,
|
|
264
|
+
) -> tuple[float, float, float, float, bool] | None:
|
|
265
|
+
width, height = image_size
|
|
266
|
+
fmt = bbox_format.lower()
|
|
267
|
+
if isinstance(box, dict):
|
|
268
|
+
if {"x1", "y1", "x2", "y2"}.issubset(box):
|
|
269
|
+
vals = [box["x1"], box["y1"], box["x2"], box["y2"]]
|
|
270
|
+
fmt = "xyxy"
|
|
271
|
+
elif {"left", "top", "right", "bottom"}.issubset(box):
|
|
272
|
+
vals = [box["left"], box["top"], box["right"], box["bottom"]]
|
|
273
|
+
fmt = "xyxy"
|
|
274
|
+
elif {"x", "y", "w", "h"}.issubset(box):
|
|
275
|
+
vals = [box["x"], box["y"], box["w"], box["h"]]
|
|
276
|
+
fmt = "xywh"
|
|
277
|
+
elif {"x", "y", "width", "height"}.issubset(box):
|
|
278
|
+
vals = [box["x"], box["y"], box["width"], box["height"]]
|
|
279
|
+
fmt = "xywh"
|
|
280
|
+
else:
|
|
281
|
+
return None
|
|
282
|
+
else:
|
|
283
|
+
try:
|
|
284
|
+
vals = [float(v) for v in box]
|
|
285
|
+
except (TypeError, ValueError):
|
|
286
|
+
return None
|
|
287
|
+
if fmt == "auto":
|
|
288
|
+
if all(0.0 <= v <= 1.0 for v in vals) and vals[2] > vals[0] and vals[3] > vals[1]:
|
|
289
|
+
fmt = "xyxy"
|
|
290
|
+
else:
|
|
291
|
+
fmt = "xywh"
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
x0, y0, a, b = [float(v) for v in vals]
|
|
295
|
+
except (TypeError, ValueError):
|
|
296
|
+
return None
|
|
297
|
+
normalized = all(0.0 <= v <= 1.0 for v in (x0, y0, a, b))
|
|
298
|
+
if fmt == "xyxy":
|
|
299
|
+
x1, y1, x2, y2 = x0, y0, a, b
|
|
300
|
+
else:
|
|
301
|
+
x1, y1, x2, y2 = x0, y0, x0 + a, y0 + b
|
|
302
|
+
if not normalized:
|
|
303
|
+
x1, x2 = x1 / max(1, width), x2 / max(1, width)
|
|
304
|
+
y1, y2 = y1 / max(1, height), y2 / max(1, height)
|
|
305
|
+
left, right = sorted((max(0.0, min(1.0, x1)), max(0.0, min(1.0, x2))))
|
|
306
|
+
top, bottom = sorted((max(0.0, min(1.0, y1)), max(0.0, min(1.0, y2))))
|
|
307
|
+
degenerate = right <= left or bottom <= top
|
|
308
|
+
if right <= left:
|
|
309
|
+
right = min(1.0, left + 1.0 / max(1, width))
|
|
310
|
+
if right <= left:
|
|
311
|
+
left = max(0.0, right - 1.0 / max(1, width))
|
|
312
|
+
if bottom <= top:
|
|
313
|
+
bottom = min(1.0, top + 1.0 / max(1, height))
|
|
314
|
+
if bottom <= top:
|
|
315
|
+
top = max(0.0, bottom - 1.0 / max(1, height))
|
|
316
|
+
if right <= left or bottom <= top:
|
|
317
|
+
return None
|
|
318
|
+
return left, top, right, bottom, degenerate
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _union_norm_boxes(boxes: list[tuple[float, float, float, float]]) -> tuple[float, float, float, float]:
|
|
322
|
+
left = min(b[0] for b in boxes)
|
|
323
|
+
top = min(b[1] for b in boxes)
|
|
324
|
+
right = max(b[2] for b in boxes)
|
|
325
|
+
bottom = max(b[3] for b in boxes)
|
|
326
|
+
return left, top, right, bottom
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _size_split_for_ratio(ratio: float) -> str:
|
|
330
|
+
if ratio < 0.005:
|
|
331
|
+
return "small"
|
|
332
|
+
if ratio < 0.05:
|
|
333
|
+
return "medium"
|
|
334
|
+
return "large"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _split_matches(ratio: float, size_split: str) -> bool:
|
|
338
|
+
if size_split == "all":
|
|
339
|
+
return True
|
|
340
|
+
lo, hi = _TEXTVQA_SIZE_THRESHOLDS[size_split]
|
|
341
|
+
return (lo is None or ratio >= lo) and (hi is None or ratio < hi)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class TextVQASizeDataset(Dataset):
|
|
345
|
+
"""TextVQA-style VQA records partitioned by answer-bbox relative size.
|
|
346
|
+
|
|
347
|
+
This loader expects local records with a question, one or more accepted
|
|
348
|
+
answers, an image path/object, and an answer bounding box. It supports both
|
|
349
|
+
JSONL and JSON files. The split follows arXiv:2502.17422:
|
|
350
|
+
``small`` ``S < 0.005``, ``medium`` ``0.005 <= S < 0.05``, and ``large``
|
|
351
|
+
``S >= 0.05``, where ``S`` is answer bbox area divided by image area.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
SPLITS = ("small", "medium", "large", "all")
|
|
355
|
+
|
|
356
|
+
def __init__(
|
|
357
|
+
self,
|
|
358
|
+
records: list[dict] | None = None,
|
|
359
|
+
path: str | None = None,
|
|
360
|
+
*,
|
|
361
|
+
image_dir: str | Path | None = None,
|
|
362
|
+
size_split: str = "small",
|
|
363
|
+
max_samples: int | None = None,
|
|
364
|
+
bbox_format: str = "auto",
|
|
365
|
+
question_key: str = "question",
|
|
366
|
+
image_key: str = "image",
|
|
367
|
+
) -> None:
|
|
368
|
+
if size_split not in self.SPLITS:
|
|
369
|
+
raise ValueError(f"unknown TextVQA size_split {size_split!r}; choose from {self.SPLITS}")
|
|
370
|
+
if bbox_format not in {"auto", "xywh", "xyxy"}:
|
|
371
|
+
raise ValueError("bbox_format must be 'auto', 'xywh', or 'xyxy'")
|
|
372
|
+
self._records = records
|
|
373
|
+
self._path = path
|
|
374
|
+
self.image_dir = image_dir
|
|
375
|
+
self.size_split = size_split
|
|
376
|
+
self.max_samples = max_samples
|
|
377
|
+
self.bbox_format = bbox_format
|
|
378
|
+
self.question_key = question_key
|
|
379
|
+
self.image_key = image_key
|
|
380
|
+
|
|
381
|
+
@classmethod
|
|
382
|
+
def from_records(cls, records: list[dict], **kw) -> "TextVQASizeDataset":
|
|
383
|
+
return cls(records=records, **kw)
|
|
384
|
+
|
|
385
|
+
@classmethod
|
|
386
|
+
def from_jsonl(cls, path: str, **kw) -> "TextVQASizeDataset":
|
|
387
|
+
return cls(path=path, **kw)
|
|
388
|
+
|
|
389
|
+
@classmethod
|
|
390
|
+
def sample(cls) -> "TextVQASizeDataset":
|
|
391
|
+
return cls(records=[
|
|
392
|
+
{
|
|
393
|
+
"question_id": "textvqa_size_sample_0",
|
|
394
|
+
"image": None,
|
|
395
|
+
"image_width": 1000,
|
|
396
|
+
"image_height": 1000,
|
|
397
|
+
"question": "What word is printed on the small sign?",
|
|
398
|
+
"answers": ["moma", "MoMA"],
|
|
399
|
+
"answer_bbox": [100, 120, 40, 12],
|
|
400
|
+
},
|
|
401
|
+
])
|
|
402
|
+
|
|
403
|
+
def load(self) -> CaseBatch:
|
|
404
|
+
records = self._records if self._records is not None else (
|
|
405
|
+
_read_json_or_jsonl(self._path) if self._path else self.sample()._records
|
|
406
|
+
)
|
|
407
|
+
cases = CaseBatch()
|
|
408
|
+
for idx, rec in enumerate(records or []):
|
|
409
|
+
answers = _textvqa_answers(rec)
|
|
410
|
+
image = _image_value(rec, self.image_dir, self.image_key)
|
|
411
|
+
image_size = _image_size(rec, image)
|
|
412
|
+
if image_size is None:
|
|
413
|
+
continue
|
|
414
|
+
raw_boxes = _flatten_boxes(_first_present(rec, _TEXTVQA_BBOX_KEYS))
|
|
415
|
+
if not raw_boxes:
|
|
416
|
+
raw_boxes = _bbox_from_ocr_tokens(rec, answers)
|
|
417
|
+
parsed_boxes = []
|
|
418
|
+
for raw in raw_boxes:
|
|
419
|
+
parsed = _box_to_norm_xyxy(
|
|
420
|
+
raw,
|
|
421
|
+
image_size=image_size,
|
|
422
|
+
bbox_format=str(rec.get("bbox_format") or self.bbox_format),
|
|
423
|
+
)
|
|
424
|
+
if parsed is not None:
|
|
425
|
+
parsed_boxes.append(parsed)
|
|
426
|
+
norm_boxes = [parsed[:4] for parsed in parsed_boxes]
|
|
427
|
+
if not norm_boxes:
|
|
428
|
+
continue
|
|
429
|
+
bbox = _union_norm_boxes(norm_boxes)
|
|
430
|
+
bbox_degenerate = any(parsed[-1] for parsed in parsed_boxes)
|
|
431
|
+
ratio = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
|
|
432
|
+
if not _split_matches(ratio, self.size_split):
|
|
433
|
+
continue
|
|
434
|
+
question_id = str(_first_present(rec, ("question_id", "id", "qid")) or f"textvqa_{idx}")
|
|
435
|
+
image_id = _first_present(rec, ("image_id", "image_name", "image_file", "filename"))
|
|
436
|
+
metadata = {
|
|
437
|
+
"dataset": "textvqa",
|
|
438
|
+
"question_id": question_id,
|
|
439
|
+
"image_id": image_id,
|
|
440
|
+
"size_split": _size_split_for_ratio(ratio),
|
|
441
|
+
"requested_size_split": self.size_split,
|
|
442
|
+
"answer_bbox_xyxy_norm": [round(v, 6) for v in bbox],
|
|
443
|
+
"bbox_area_ratio": ratio,
|
|
444
|
+
"bbox_degenerate": bbox_degenerate,
|
|
445
|
+
"answers": answers,
|
|
446
|
+
}
|
|
447
|
+
cases.append(FailureCase(
|
|
448
|
+
id=question_id,
|
|
449
|
+
inputs=Inputs(prompt=str(rec.get(self.question_key, "")), image=image),
|
|
450
|
+
expected=_textvqa_rubric(answers),
|
|
451
|
+
tags={"vlm_qa", "textvqa", f"textvqa_{metadata['size_split']}"},
|
|
452
|
+
provenance=Provenance(source=Source.DATASET),
|
|
453
|
+
metadata=metadata,
|
|
454
|
+
))
|
|
455
|
+
if self.max_samples is not None and len(cases) >= self.max_samples:
|
|
456
|
+
break
|
|
457
|
+
return cases
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# Spatial457 — RyanWW/Spatial457 row schema: {image: PIL, image_filename, question, answer, program, question_index}
|
|
461
|
+
_SPATIAL457_SAMPLE = [
|
|
462
|
+
{"image": None, "image_filename": "superCLEVR_new_000001.png", "question_index": 100001,
|
|
463
|
+
"question": "Is the large red object in front of the yellow car?", "answer": "True"},
|
|
464
|
+
{"image": None, "image_filename": "superCLEVR_new_000002.png", "question_index": 100002,
|
|
465
|
+
"question": "How many objects are behind the blue cube?", "answer": "2"},
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class Spatial457Dataset(Dataset):
|
|
470
|
+
"""RyanWW/Spatial457 — 6D spatial-reasoning VQA (CVPR'25), 7 cascading subtypes.
|
|
471
|
+
|
|
472
|
+
``subset`` selects a difficulty/question-type split. ``load()`` pulls from
|
|
473
|
+
HuggingFace (``pip install evalrx[data]``); use ``from_records`` / ``sample``
|
|
474
|
+
offline. Maps each row to a ``vlm_qa`` FailureCase (image -> Inputs.image,
|
|
475
|
+
answer -> expected; image_filename/question_index/subset -> metadata).
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
SUBTYPES = (
|
|
479
|
+
"L1_single", "L2_objects", "L3_2d_spatial", "L4_occ", "L4_pose",
|
|
480
|
+
"L5_6d_spatial", "L5_collision",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def __init__(
|
|
484
|
+
self,
|
|
485
|
+
subset: str = "L5_6d_spatial",
|
|
486
|
+
split: str = "test",
|
|
487
|
+
*,
|
|
488
|
+
records: list[dict] | None = None,
|
|
489
|
+
hf_repo: str = "RyanWW/Spatial457",
|
|
490
|
+
max_samples: int | None = None,
|
|
491
|
+
) -> None:
|
|
492
|
+
if subset not in self.SUBTYPES:
|
|
493
|
+
raise ValueError(f"unknown Spatial457 subset {subset!r}; choose from {self.SUBTYPES}")
|
|
494
|
+
self.subset = subset
|
|
495
|
+
self.split = split
|
|
496
|
+
self.hf_repo = hf_repo
|
|
497
|
+
self.max_samples = max_samples
|
|
498
|
+
self._records = records
|
|
499
|
+
|
|
500
|
+
@classmethod
|
|
501
|
+
def from_records(cls, records: list[dict], subset: str = "L5_6d_spatial") -> "Spatial457Dataset":
|
|
502
|
+
return cls(subset=subset, records=records)
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def sample(cls, subset: str = "L5_6d_spatial") -> "Spatial457Dataset":
|
|
506
|
+
return cls(subset=subset, records=_SPATIAL457_SAMPLE)
|
|
507
|
+
|
|
508
|
+
def _load_hf(self) -> Iterable[dict]:
|
|
509
|
+
try:
|
|
510
|
+
from datasets import load_dataset
|
|
511
|
+
except ImportError as e: # pragma: no cover - optional dep
|
|
512
|
+
raise ImportError(
|
|
513
|
+
"Spatial457Dataset.load() needs the HuggingFace 'datasets' library — "
|
|
514
|
+
"pip install evalrx[data] (or use from_records()/sample() offline)."
|
|
515
|
+
) from e
|
|
516
|
+
try: # subtypes are exposed as dataset configs
|
|
517
|
+
ds = load_dataset(self.hf_repo, self.subset, split=self.split)
|
|
518
|
+
except Exception: # fall back: single config, filter by a subtype column if present
|
|
519
|
+
ds = load_dataset(self.hf_repo, split=self.split)
|
|
520
|
+
for col in ("question_type", "subtype", "level"):
|
|
521
|
+
if col in (getattr(ds, "column_names", None) or []):
|
|
522
|
+
ds = ds.filter(lambda r, c=col: r.get(c) == self.subset)
|
|
523
|
+
break
|
|
524
|
+
if self.max_samples is not None:
|
|
525
|
+
ds = ds.select(range(min(self.max_samples, len(ds))))
|
|
526
|
+
return ds
|
|
527
|
+
|
|
528
|
+
def load(self) -> CaseBatch:
|
|
529
|
+
rows = self._records if self._records is not None else self._load_hf()
|
|
530
|
+
return _vlm_cases(
|
|
531
|
+
rows, prompt_key="question", answer_key="answer", image_key="image",
|
|
532
|
+
tags={"vlm_qa", "spatial457", self.subset},
|
|
533
|
+
meta_keys=("image_filename", "question_index", "program"),
|
|
534
|
+
base_meta={"dataset": "spatial457", "subset": self.subset},
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# VQA-RAD — flaviagiammarino/vqa-rad row schema: {image: PIL, question, answer}
|
|
539
|
+
_VQA_RAD_SAMPLE = [
|
|
540
|
+
{"image": None, "question": "what imaging modality was used?", "answer": "ct"},
|
|
541
|
+
{"image": None, "question": "is there evidence of a pneumothorax?", "answer": "no"},
|
|
542
|
+
{"image": None, "question": "is the heart enlarged?", "answer": "yes"},
|
|
543
|
+
{"image": None, "question": "what plane is this image taken in?", "answer": "axial"},
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
# Question fragments that identify "easy" identification questions (modality /
|
|
547
|
+
# plane / organ) a general VLM reliably answers — the M4 control (PASS) group.
|
|
548
|
+
_VQA_RAD_EASY_FRAGMENTS = (
|
|
549
|
+
"modality", "what plane", "which plane", "plane is", "what organ",
|
|
550
|
+
"which organ", "organ system", "part of the body", "what type of imaging",
|
|
551
|
+
"what kind of image", "what imaging", "mri or ct", "ct or mri",
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _categorize_vqa_rad(question: str, answer: str) -> str:
|
|
556
|
+
"""``"easy"`` (modality/plane/organ), ``"presence"`` (closed yes/no), or ``"other"``."""
|
|
557
|
+
q = " ".join(str(question).lower().split())
|
|
558
|
+
if any(frag in q for frag in _VQA_RAD_EASY_FRAGMENTS):
|
|
559
|
+
return "easy"
|
|
560
|
+
if str(answer).strip().lower() in {"yes", "no"}:
|
|
561
|
+
return "presence"
|
|
562
|
+
return "other"
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _easy_answer_rubric(gold: str) -> dict:
|
|
566
|
+
"""Token-level ``any_of`` rubric for open identification answers.
|
|
567
|
+
|
|
568
|
+
VQA-RAD gold strings are messy ("xray - plain film", tab-separated organ
|
|
569
|
+
lists) — whole-string matching would mislabel correct answers as FAIL and
|
|
570
|
+
pollute the control group. Accept any significant gold token instead, with
|
|
571
|
+
a cheap plural tolerance (kidneys → kidney).
|
|
572
|
+
"""
|
|
573
|
+
import re as _re
|
|
574
|
+
|
|
575
|
+
tokens: list[str] = []
|
|
576
|
+
for tok in _re.findall(r"[a-z0-9]+", str(gold).lower()):
|
|
577
|
+
if len(tok) < 2 or tok in tokens:
|
|
578
|
+
continue
|
|
579
|
+
tokens.append(tok)
|
|
580
|
+
if tok.endswith("s") and len(tok) > 3 and tok[:-1] not in tokens:
|
|
581
|
+
tokens.append(tok[:-1])
|
|
582
|
+
return {"any_of": tokens or [str(gold).strip().lower()]}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class VQARADDataset(Dataset):
|
|
586
|
+
"""VQA-RAD — radiology VQA (Lau et al., 2018), public domain (CC0).
|
|
587
|
+
|
|
588
|
+
Builds a **diagnosis-ready** case mix for the VL failure-analysis loop:
|
|
589
|
+
|
|
590
|
+
- ``n_easy`` identification questions (modality/plane/organ) the model
|
|
591
|
+
reliably PASSES — M4's control group;
|
|
592
|
+
- ``n_presence`` closed yes/no finding-presence questions, balanced between
|
|
593
|
+
gold "yes" and gold "no" — where presence hallucination (yes-bias)
|
|
594
|
+
concentrates the failures.
|
|
595
|
+
|
|
596
|
+
Presence cases carry ``metadata["pope_label"]`` so the ``pope`` analyzer can
|
|
597
|
+
score them and emit per-case ``false_positive`` / ``false_negative``
|
|
598
|
+
mechanism signals, plus a strict ``{"all_of": [gold], "none_of": [other]}``
|
|
599
|
+
rubric for the discovery scorer. Easy cases get a token-level ``any_of``
|
|
600
|
+
rubric (gold strings are messy free text).
|
|
601
|
+
|
|
602
|
+
Data: https://huggingface.co/datasets/flaviagiammarino/vqa-rad
|
|
603
|
+
Paper: "A dataset of clinically generated visual questions and answers
|
|
604
|
+
about radiology images" — Lau et al., Scientific Data 2018.
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
def __init__(
|
|
608
|
+
self,
|
|
609
|
+
split: str = "train",
|
|
610
|
+
*,
|
|
611
|
+
records: list[dict] | None = None,
|
|
612
|
+
hf_repo: str = "flaviagiammarino/vqa-rad",
|
|
613
|
+
n_easy: int = 6,
|
|
614
|
+
n_presence: int = 12,
|
|
615
|
+
seed: int = 0,
|
|
616
|
+
) -> None:
|
|
617
|
+
self.split = split
|
|
618
|
+
self.hf_repo = hf_repo
|
|
619
|
+
self.n_easy = n_easy
|
|
620
|
+
self.n_presence = n_presence
|
|
621
|
+
self.seed = seed
|
|
622
|
+
self._records = records
|
|
623
|
+
|
|
624
|
+
@classmethod
|
|
625
|
+
def from_records(cls, records: list[dict], **kw) -> "VQARADDataset":
|
|
626
|
+
return cls(records=records, **kw)
|
|
627
|
+
|
|
628
|
+
@classmethod
|
|
629
|
+
def sample(cls) -> "VQARADDataset":
|
|
630
|
+
return cls(records=_VQA_RAD_SAMPLE, n_easy=2, n_presence=2)
|
|
631
|
+
|
|
632
|
+
def _load_hf(self) -> Iterable[dict]:
|
|
633
|
+
try:
|
|
634
|
+
from datasets import load_dataset
|
|
635
|
+
except ImportError as e: # pragma: no cover - optional dep
|
|
636
|
+
raise ImportError(
|
|
637
|
+
"VQARADDataset.load() needs the HuggingFace 'datasets' library — "
|
|
638
|
+
"pip install evalrx[data] (or use from_records()/sample() offline)."
|
|
639
|
+
) from e
|
|
640
|
+
return load_dataset(self.hf_repo, split=self.split)
|
|
641
|
+
|
|
642
|
+
def load(self) -> CaseBatch:
|
|
643
|
+
import random
|
|
644
|
+
|
|
645
|
+
rows = self._records if self._records is not None else self._load_hf()
|
|
646
|
+
|
|
647
|
+
easy: list[dict] = []
|
|
648
|
+
pres_yes: list[dict] = []
|
|
649
|
+
pres_no: list[dict] = []
|
|
650
|
+
for rec in rows:
|
|
651
|
+
cat = _categorize_vqa_rad(rec.get("question", ""), rec.get("answer", ""))
|
|
652
|
+
if cat == "easy":
|
|
653
|
+
easy.append(rec)
|
|
654
|
+
elif cat == "presence":
|
|
655
|
+
(pres_yes if str(rec["answer"]).strip().lower() == "yes" else pres_no).append(rec)
|
|
656
|
+
|
|
657
|
+
rng = random.Random(self.seed)
|
|
658
|
+
rng.shuffle(easy)
|
|
659
|
+
rng.shuffle(pres_yes)
|
|
660
|
+
rng.shuffle(pres_no)
|
|
661
|
+
|
|
662
|
+
half = self.n_presence // 2
|
|
663
|
+
picked_pres = pres_yes[:half] + pres_no[: self.n_presence - half]
|
|
664
|
+
picked_easy = easy[: self.n_easy]
|
|
665
|
+
|
|
666
|
+
out = CaseBatch()
|
|
667
|
+
for i, rec in enumerate(picked_easy):
|
|
668
|
+
out.append(FailureCase(
|
|
669
|
+
id=f"rad_easy_{i}",
|
|
670
|
+
inputs=Inputs(
|
|
671
|
+
prompt=f"{str(rec['question']).strip()} Answer briefly.",
|
|
672
|
+
image=rec.get("image"),
|
|
673
|
+
),
|
|
674
|
+
expected=_easy_answer_rubric(rec["answer"]),
|
|
675
|
+
tags={"vlm_qa", "med_vqa", "vqa_rad", "easy"},
|
|
676
|
+
provenance=Provenance(source=Source.DATASET),
|
|
677
|
+
metadata={"dataset": "vqa_rad", "category": "easy",
|
|
678
|
+
"gold_answer": str(rec["answer"]).strip()},
|
|
679
|
+
))
|
|
680
|
+
for i, rec in enumerate(picked_pres):
|
|
681
|
+
gold = str(rec["answer"]).strip().lower()
|
|
682
|
+
other = "no" if gold == "yes" else "yes"
|
|
683
|
+
out.append(FailureCase(
|
|
684
|
+
id=f"rad_pres_{i}",
|
|
685
|
+
inputs=Inputs(
|
|
686
|
+
prompt=f"{str(rec['question']).strip()} Answer yes or no.",
|
|
687
|
+
image=rec.get("image"),
|
|
688
|
+
),
|
|
689
|
+
expected={"all_of": [gold], "none_of": [other]},
|
|
690
|
+
tags={"vlm_qa", "med_vqa", "vqa_rad", "presence"},
|
|
691
|
+
provenance=Provenance(source=Source.DATASET),
|
|
692
|
+
metadata={"dataset": "vqa_rad", "category": "presence",
|
|
693
|
+
"pope_label": gold, "gold_answer": gold},
|
|
694
|
+
))
|
|
695
|
+
return out
|