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,2074 @@
|
|
|
1
|
+
"""HF-local backend — the only InternalsHandle path.
|
|
2
|
+
|
|
3
|
+
Loads any spec via ``transformers`` and captures internals. Two-tier capture:
|
|
4
|
+
|
|
5
|
+
* **HF flags** (``output_attentions`` / ``output_hidden_states`` / logits) cover
|
|
6
|
+
attention, residual-stream hidden states and logits with NO module-path
|
|
7
|
+
surgery — this is the high-value common path.
|
|
8
|
+
* **hooks + runtime path discovery** (:mod:`evalrx.models._discover`) are
|
|
9
|
+
only needed beyond what flags give (activation patching, MoE routing,
|
|
10
|
+
gradients) — reserved for Stage 2.
|
|
11
|
+
|
|
12
|
+
Attention capture REQUIRES eager attention (sdpa/flash silently return ``None``),
|
|
13
|
+
so when the model declares ``eager_required_for_attn`` we force
|
|
14
|
+
``attn_implementation="eager"``. ``torch`` / ``transformers`` are imported
|
|
15
|
+
lazily so this module imports on a torch-free install.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from evalrx.core.capability import Capability, CapabilityError
|
|
26
|
+
from evalrx.core.case import Inputs
|
|
27
|
+
from evalrx.core.model import Model, TokenLogprob, Trace
|
|
28
|
+
from evalrx.core.spec import AttnSemantics
|
|
29
|
+
from evalrx.core.tool import ChatTurn
|
|
30
|
+
from evalrx.models.backends.base import Backend, RuntimeConfig
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# capability -> HF forward flag
|
|
35
|
+
_CAPTURE_FLAGS = {
|
|
36
|
+
Capability.ATTENTION: "output_attentions",
|
|
37
|
+
Capability.HIDDEN_STATES: "output_hidden_states",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_nested_attr(obj: Any, attr_path: "str | None", *, default: Any) -> Any:
|
|
42
|
+
"""Walk a dotted attribute path on *obj*, returning *default* if any step is missing."""
|
|
43
|
+
if not attr_path:
|
|
44
|
+
return default
|
|
45
|
+
for part in attr_path.split("."):
|
|
46
|
+
obj = getattr(obj, part, None)
|
|
47
|
+
if obj is None:
|
|
48
|
+
return default
|
|
49
|
+
return obj
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _populate_vision_extras(
|
|
53
|
+
extras: dict,
|
|
54
|
+
input_ids: Any, # CPU torch.Tensor
|
|
55
|
+
proc_in: dict,
|
|
56
|
+
model_config: Any,
|
|
57
|
+
vision: Any, # VisionSpec — avoid circular import; duck-typed
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Fill *extras* with image-token mask and spatial layout for analyzers.
|
|
60
|
+
|
|
61
|
+
Called from ``HFLocalModel._vlm_forward`` after the processor runs.
|
|
62
|
+
Writes:
|
|
63
|
+
``image_token_mask`` — bool tensor (seq_len,) marking image-pad positions.
|
|
64
|
+
``image_spatial_shape`` — (H, W) patch grid after spatial merge, for reshaping.
|
|
65
|
+
``image_grid_thw`` — raw (T, H, W) tensor if grid_source=="grid_thw".
|
|
66
|
+
"""
|
|
67
|
+
# A dotted attr (e.g. Omni's "thinker_config.image_token_id") needs the
|
|
68
|
+
# nested walker; a bare name behaves identically to getattr(), so no VLM
|
|
69
|
+
# spec's resolution changes.
|
|
70
|
+
image_token_id = _read_nested_attr(model_config, vision.image_token_id_attr, default=None)
|
|
71
|
+
if image_token_id is not None:
|
|
72
|
+
extras["image_token_mask"] = input_ids == image_token_id
|
|
73
|
+
|
|
74
|
+
merge = int(_read_nested_attr(model_config, vision.merge_size_attr, default=1) or 1)
|
|
75
|
+
|
|
76
|
+
if vision.grid_source == "grid_thw":
|
|
77
|
+
grid_t = proc_in.get("image_grid_thw")
|
|
78
|
+
if grid_t is not None:
|
|
79
|
+
grid = grid_t.cpu() if hasattr(grid_t, "cpu") else grid_t
|
|
80
|
+
extras["image_grid_thw"] = grid
|
|
81
|
+
_, h, w = int(grid[0, 0]), int(grid[0, 1]), int(grid[0, 2])
|
|
82
|
+
extras["image_spatial_shape"] = (h // merge, w // merge)
|
|
83
|
+
elif vision.grid_source == "grid_hw":
|
|
84
|
+
grid_t = proc_in.get("image_grid_hw")
|
|
85
|
+
if grid_t is not None:
|
|
86
|
+
grid = grid_t.cpu() if hasattr(grid_t, "cpu") else grid_t
|
|
87
|
+
extras["image_grid_hw"] = grid
|
|
88
|
+
h, w = int(grid[0, 0]), int(grid[0, 1])
|
|
89
|
+
extras["image_spatial_shape"] = (h // merge, w // merge)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _populate_audio_extras(
|
|
93
|
+
extras: dict,
|
|
94
|
+
input_ids: Any, # CPU torch.Tensor
|
|
95
|
+
model_config: Any,
|
|
96
|
+
audio: Any, # AudioSpec — avoid circular import; duck-typed
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Fill *extras* with the audio-token mask (the audio TokenTypeMap analog).
|
|
99
|
+
|
|
100
|
+
Symmetric to :func:`_populate_vision_extras`, minus the spatial-grid part —
|
|
101
|
+
audio placeholders are a flat run of one token per encoded frame-group, no
|
|
102
|
+
2D reshape. Writes ``audio_token_mask`` — bool tensor (seq_len,) marking
|
|
103
|
+
audio-placeholder positions — which downstream paper methods (e.g. a
|
|
104
|
+
contrastive-decoding audio-reliance signal) read to isolate how much of the
|
|
105
|
+
decoder's attention lands on audio vs. text/image tokens.
|
|
106
|
+
"""
|
|
107
|
+
audio_token_id = _read_nested_attr(model_config, audio.audio_token_id_attr, default=None)
|
|
108
|
+
if audio_token_id is not None:
|
|
109
|
+
extras["audio_token_mask"] = input_ids == audio_token_id
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_image(obj: Any) -> Any:
|
|
113
|
+
"""Return a PIL image for *obj* (PIL passes through; str/Path is opened)."""
|
|
114
|
+
if hasattr(obj, "size") and hasattr(obj, "mode"): # already PIL-like
|
|
115
|
+
return obj
|
|
116
|
+
from PIL import Image
|
|
117
|
+
|
|
118
|
+
return Image.open(obj).convert("RGB")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Sampling rate contract for ``Inputs.audio``: an ndarray is expected to already
|
|
122
|
+
# be mono float32 at this rate (matches the WhisperFeatureExtractor every
|
|
123
|
+
# audio-capable spec in this codebase uses). A path/URL is decoded to it here,
|
|
124
|
+
# so callers never have to think about resampling.
|
|
125
|
+
AUDIO_SAMPLE_RATE = 16000
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_audio(obj: Any) -> Any:
|
|
129
|
+
"""Return a mono float32 waveform (numpy array) at :data:`AUDIO_SAMPLE_RATE`.
|
|
130
|
+
|
|
131
|
+
An already-decoded array passes through UNCHANGED — the caller is on the
|
|
132
|
+
hook for it being mono float32 @ 16 kHz; there is no signal here to detect
|
|
133
|
+
or fix a mismatched sample rate, so getting this wrong fails silently
|
|
134
|
+
downstream (the model just hears audio sped up/slowed down). A str/Path is
|
|
135
|
+
decoded via ``ffmpeg`` (already assumed present elsewhere in this codebase,
|
|
136
|
+
e.g. ``analysis/workbench.py``'s duration probing) rather than adding a new
|
|
137
|
+
audio-decoding dependency.
|
|
138
|
+
"""
|
|
139
|
+
if hasattr(obj, "dtype") and hasattr(obj, "shape"): # already a numpy array
|
|
140
|
+
import numpy as np
|
|
141
|
+
|
|
142
|
+
arr = np.asarray(obj, dtype=np.float32)
|
|
143
|
+
if arr.ndim > 1:
|
|
144
|
+
# Silently flattening a (channels, samples) array would interleave
|
|
145
|
+
# channels into one stream -- audio at the wrong speed with channel
|
|
146
|
+
# aliasing, no error anywhere downstream. Mono is the documented
|
|
147
|
+
# contract; enforce it instead of guessing which axis is channels.
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Inputs.audio must be a 1-D mono waveform, got shape {arr.shape}; "
|
|
150
|
+
"mix down to mono before passing it in"
|
|
151
|
+
)
|
|
152
|
+
return arr.reshape(-1)
|
|
153
|
+
|
|
154
|
+
import shutil
|
|
155
|
+
import subprocess
|
|
156
|
+
|
|
157
|
+
import numpy as np
|
|
158
|
+
|
|
159
|
+
if shutil.which("ffmpeg") is None:
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
"decoding audio from a path/URL requires the 'ffmpeg' binary on PATH; "
|
|
162
|
+
"install it, or pass Inputs.audio as an already-decoded mono float32 "
|
|
163
|
+
f"numpy array at {AUDIO_SAMPLE_RATE} Hz"
|
|
164
|
+
)
|
|
165
|
+
cmd = [
|
|
166
|
+
"ffmpeg", "-v", "error", "-i", str(obj),
|
|
167
|
+
"-f", "f32le", "-acodec", "pcm_f32le", "-ac", "1", "-ar", str(AUDIO_SAMPLE_RATE), "-",
|
|
168
|
+
]
|
|
169
|
+
proc = subprocess.run(cmd, capture_output=True, check=False)
|
|
170
|
+
if proc.returncode != 0:
|
|
171
|
+
logger.warning("ffmpeg failed to decode audio %r: %s", obj, proc.stderr.decode(errors="replace"))
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"ffmpeg failed to decode audio {obj!r}: {proc.stderr.decode(errors='replace')}"
|
|
174
|
+
)
|
|
175
|
+
wav = np.frombuffer(proc.stdout, dtype="<f4").copy()
|
|
176
|
+
logger.debug("decoded audio %r via ffmpeg: %.1fs @ %dHz", obj, len(wav) / AUDIO_SAMPLE_RATE, AUDIO_SAMPLE_RATE)
|
|
177
|
+
return wav
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _check_audio_duration(audios: list, processor: Any, model_key: str) -> None:
|
|
181
|
+
"""Raise before the processor silently truncates audio past its encoder window.
|
|
182
|
+
|
|
183
|
+
``WhisperFeatureExtractor.chunk_length`` (seconds) differs per checkpoint —
|
|
184
|
+
Qwen2-Audio's is 30s, Qwen2.5-Omni's is 300s — and padding beyond it is
|
|
185
|
+
silently dropped rather than erroring (verified empirically: token count
|
|
186
|
+
and ``feature_attention_mask`` both cap at the window with no warning).
|
|
187
|
+
Read live from the processor, never baked, per this module's convention.
|
|
188
|
+
"""
|
|
189
|
+
feature_extractor = getattr(processor, "feature_extractor", None)
|
|
190
|
+
chunk_length = getattr(feature_extractor, "chunk_length", None)
|
|
191
|
+
if chunk_length is None:
|
|
192
|
+
return # nothing to check this checkpoint's contract against
|
|
193
|
+
# ``audios`` are already resolved to AUDIO_SAMPLE_RATE by _resolve_audio, but
|
|
194
|
+
# the limit itself is read from the feature extractor's own rate rather than
|
|
195
|
+
# the module constant, so this stays correct if that ever diverges.
|
|
196
|
+
sampling_rate = getattr(feature_extractor, "sampling_rate", AUDIO_SAMPLE_RATE)
|
|
197
|
+
limit_samples = int(chunk_length) * int(sampling_rate)
|
|
198
|
+
for i, wav in enumerate(audios):
|
|
199
|
+
if len(wav) > limit_samples:
|
|
200
|
+
duration_sec = len(wav) / sampling_rate
|
|
201
|
+
logger.warning(
|
|
202
|
+
"%s: audio[%d] is %.1fs, longer than the %ds encoder window — refusing rather "
|
|
203
|
+
"than letting the processor silently truncate it",
|
|
204
|
+
model_key, i, duration_sec, chunk_length,
|
|
205
|
+
)
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"{model_key}: audio[{i}] is {duration_sec:.1f}s, longer than "
|
|
208
|
+
f"this checkpoint's {chunk_length}s encoder window — it would be silently "
|
|
209
|
+
"truncated rather than raising inside the processor. Chunk the audio yourself "
|
|
210
|
+
"before calling, or accept a documented context window in the caller."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _new_tokens(out: Any, input_ids: Any) -> Any:
|
|
215
|
+
"""The newly generated token ids from a ``generate()`` output sequence.
|
|
216
|
+
|
|
217
|
+
``out`` is normally the prompt concatenated with the continuation, so the
|
|
218
|
+
new tokens start after ``len(input_ids)``. A model whose own
|
|
219
|
+
``generate()`` converts the input to ``inputs_embeds`` internally
|
|
220
|
+
(Qwen3-Omni and other omni/audio architectures whose thinker merges
|
|
221
|
+
audio/vision embeddings before the LM) never hands ``input_ids`` back to
|
|
222
|
+
``GenerationMixin`` — which then cannot prepend prompt token ids it was
|
|
223
|
+
never given, so it returns ONLY the new tokens (transformers itself warns
|
|
224
|
+
about this: "... without input_ids to generate ..."). Slicing at
|
|
225
|
+
``len(input_ids)`` in that case cuts past the end of a sequence shorter
|
|
226
|
+
than the prompt and silently decodes to "" — seen live on
|
|
227
|
+
Qwen3-Omni-30B-A3B-Instruct/MMAU, every one of 128 cases, 2026-08-27.
|
|
228
|
+
|
|
229
|
+
A length check alone (``out.shape[0] > len(input_ids)``) is ambiguous at
|
|
230
|
+
the edges — a concatenated output with zero new tokens, or an
|
|
231
|
+
inputs_embeds-only output that happens to be exactly as long as the
|
|
232
|
+
prompt — so this compares the actual leading token ids: only a real
|
|
233
|
+
prefix match means ``out`` truly contains the prompt to cut off.
|
|
234
|
+
"""
|
|
235
|
+
import torch
|
|
236
|
+
|
|
237
|
+
input_len = len(input_ids)
|
|
238
|
+
if out.shape[0] >= input_len and torch.equal(out[:input_len], torch.as_tensor(input_ids)):
|
|
239
|
+
return out[input_len:]
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _collect_message_images(messages: list) -> list:
|
|
244
|
+
"""Extract images from chat messages in appearance order.
|
|
245
|
+
|
|
246
|
+
Messages follow the transformers content-block convention: ``content`` is a
|
|
247
|
+
plain string OR a list of blocks, where an image block is
|
|
248
|
+
``{"type": "image", "image": <PIL | path>}``. The block order across the
|
|
249
|
+
whole conversation must match the ``images=`` list handed to the processor.
|
|
250
|
+
"""
|
|
251
|
+
images: list = []
|
|
252
|
+
for msg in messages:
|
|
253
|
+
content = msg.get("content")
|
|
254
|
+
if not isinstance(content, list):
|
|
255
|
+
continue
|
|
256
|
+
for block in content:
|
|
257
|
+
if isinstance(block, dict) and block.get("type") == "image":
|
|
258
|
+
img = block.get("image")
|
|
259
|
+
if img is not None:
|
|
260
|
+
images.append(_resolve_image(img))
|
|
261
|
+
return images
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class HFLocalModel(Model):
|
|
265
|
+
"""A locally-loaded HF model, constructed from a :class:`ModelSpec`."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, spec, runtime: RuntimeConfig) -> None:
|
|
268
|
+
self.spec = spec
|
|
269
|
+
self.runtime = runtime
|
|
270
|
+
self._hf = None # (model, processor) — lazy
|
|
271
|
+
self._ifcd_editors: dict[str, Any] = {}
|
|
272
|
+
self._detect_engines: dict[str, Any] = {}
|
|
273
|
+
self._audio_text_engines: dict[tuple[str, str], Any] = {}
|
|
274
|
+
self._audio_api_specialist_engines: dict[str, Any] = {}
|
|
275
|
+
self._vision_api_specialist_engines: dict[str, Any] = {}
|
|
276
|
+
self._vision_specialist_engines: dict[str, Any] = {}
|
|
277
|
+
caps = {
|
|
278
|
+
Capability.GENERATE,
|
|
279
|
+
Capability.LOGITS,
|
|
280
|
+
Capability.LOGPROBS,
|
|
281
|
+
Capability.HIDDEN_STATES,
|
|
282
|
+
}
|
|
283
|
+
if spec.attn_semantics is not AttnSemantics.NONE:
|
|
284
|
+
caps.add(Capability.ATTENTION)
|
|
285
|
+
# TOOL_CALLS is a CONDITIONAL capability for local models: the backend
|
|
286
|
+
# provides the channel, but tool-calling only works if the model's chat
|
|
287
|
+
# template renders tools (declared per-model via spec.tool_calling).
|
|
288
|
+
if spec.tool_calling:
|
|
289
|
+
caps.add(Capability.TOOL_CALLS)
|
|
290
|
+
self.capabilities = frozenset(caps)
|
|
291
|
+
self.modalities = frozenset(spec.modalities) # text / image / audio / video, from the spec
|
|
292
|
+
|
|
293
|
+
def paper_method_fidelity(self, method: str) -> str:
|
|
294
|
+
"""Declare whether a paper-method adapter is exact or architecture-adapted.
|
|
295
|
+
|
|
296
|
+
``FixAgent`` admits architecture-native paper routes by default. An
|
|
297
|
+
experiment may explicitly opt into ``"adapted"`` methods, but reports
|
|
298
|
+
must retain that distinction rather than treating a same-formula port
|
|
299
|
+
to a different model architecture as a paper reproduction.
|
|
300
|
+
"""
|
|
301
|
+
fidelity = self._paper_method_fidelity_impl(method)
|
|
302
|
+
logger.debug("%s: paper_method_fidelity(%r) -> %r", self.spec.key, method, fidelity)
|
|
303
|
+
return fidelity
|
|
304
|
+
|
|
305
|
+
def _paper_method_fidelity_impl(self, method: str) -> str:
|
|
306
|
+
if method == "vcd":
|
|
307
|
+
# The executor uses the released corruption, plausibility cutoff,
|
|
308
|
+
# and per-token sampler. Image-specific seeding keeps paired
|
|
309
|
+
# evaluation independent of iteration order.
|
|
310
|
+
return "per_item_seeded_sampler_specialization"
|
|
311
|
+
if method == "icd":
|
|
312
|
+
return (
|
|
313
|
+
"native_binary_specialization"
|
|
314
|
+
if self.spec.model_type == "instructblip"
|
|
315
|
+
else "adapted"
|
|
316
|
+
)
|
|
317
|
+
if method == "vicrop":
|
|
318
|
+
return (
|
|
319
|
+
"native_selector_specialization" if self.spec.model_type == "llava" else "adapted"
|
|
320
|
+
)
|
|
321
|
+
if method == "opera":
|
|
322
|
+
# This preserves OPERA's first-token over-trust penalty for POPE's
|
|
323
|
+
# binary task. Its beam rollback is not meaningful when exactly
|
|
324
|
+
# one output token is evaluated.
|
|
325
|
+
return (
|
|
326
|
+
"native_binary_specialization" if self.spec.model_type == "llava" else "unavailable"
|
|
327
|
+
)
|
|
328
|
+
if method == "ifcd":
|
|
329
|
+
checkpoint = self.runtime.engine_kwargs.get("ifcd_checkpoint")
|
|
330
|
+
if self.spec.model_type == "llava" and checkpoint and Path(str(checkpoint)).is_file():
|
|
331
|
+
# The public Vicuna TruthX artifact is compatible with
|
|
332
|
+
# LLaVA-1.5's decoder but is not the paper's MSCOCO-trained
|
|
333
|
+
# editor, and modern HF hooks after ``o_proj``. Never promote
|
|
334
|
+
# this to an exact IFCD reproduction.
|
|
335
|
+
return "adapted_truthx_artifact"
|
|
336
|
+
return "unavailable"
|
|
337
|
+
if method == "pai":
|
|
338
|
+
return (
|
|
339
|
+
"native_attention_cfg_specialization"
|
|
340
|
+
if self.spec.model_type == "llava"
|
|
341
|
+
else "unavailable"
|
|
342
|
+
)
|
|
343
|
+
if method == "tcd":
|
|
344
|
+
if self.spec.audio is None:
|
|
345
|
+
return "unavailable"
|
|
346
|
+
# Eq. 4 zips per-layer encoder stability with per-layer decoder
|
|
347
|
+
# audio-attention ratio index-for-index — faithful only when both
|
|
348
|
+
# towers have the same layer count. Qwen2-Audio-Instruct's
|
|
349
|
+
# Whisper-style encoder and Qwen2 decoder both have 32 layers
|
|
350
|
+
# (verified against the live config, not assumed); the paper's
|
|
351
|
+
# own hyperparameters (Appendix A) are anchored on this
|
|
352
|
+
# checkpoint. Every other registered audio spec has a depth
|
|
353
|
+
# mismatch (e.g. Qwen2.5-Omni: 32 encoder / 28 decoder), so
|
|
354
|
+
# generate_tcd truncates both to min(...) there instead of
|
|
355
|
+
# raising -- report that truncation as an adaptation.
|
|
356
|
+
return (
|
|
357
|
+
"native_layer_matched_stability"
|
|
358
|
+
if self.spec.key == "qwen2-audio-7b-instruct"
|
|
359
|
+
else "adapted_truncated_layer_stability"
|
|
360
|
+
)
|
|
361
|
+
if method == "aad":
|
|
362
|
+
# Unlike TCD, AAD's contrast (real audio vs. the same prompt with
|
|
363
|
+
# the waveform silenced) never reads architecture internals -- no
|
|
364
|
+
# layer counts, no attention weights, no audio-token span. The
|
|
365
|
+
# released repo's own claim is that this generalises across
|
|
366
|
+
# audio LALMs (Qwen2-Audio and SALMONN both evaluated); this
|
|
367
|
+
# codebase's registered audio specs are exactly the checkpoints
|
|
368
|
+
# that claim covers, so any of them is native, not adapted.
|
|
369
|
+
return "native_silence_contrast" if self.spec.audio is not None else "unavailable"
|
|
370
|
+
return "unavailable"
|
|
371
|
+
|
|
372
|
+
@classmethod
|
|
373
|
+
def from_loaded(
|
|
374
|
+
cls, model, tokenizer, spec=None, runtime: "RuntimeConfig | None" = None
|
|
375
|
+
) -> "HFLocalModel":
|
|
376
|
+
"""Wrap an ALREADY-LOADED HF model + tokenizer (the ``wrap()`` on-ramp).
|
|
377
|
+
|
|
378
|
+
Unlike the spec-driven path, no weights are fetched: we inject the live
|
|
379
|
+
``(model, processor)`` directly and skip :meth:`load`. The spec is inferred
|
|
380
|
+
from the model when not given, and the attention capability is verified
|
|
381
|
+
against the live ``attn_implementation`` (eager is required, else the model
|
|
382
|
+
returns ``None`` attentions) — flipping it to eager when possible.
|
|
383
|
+
"""
|
|
384
|
+
from evalrx.models.inference import infer_spec
|
|
385
|
+
|
|
386
|
+
spec = spec or infer_spec(model, tokenizer)
|
|
387
|
+
self = cls(spec, runtime or RuntimeConfig())
|
|
388
|
+
self._apply_family_shims(model, tokenizer)
|
|
389
|
+
self._hf = (model, tokenizer) # bypass lazy load(); a bare tokenizer acts as processor
|
|
390
|
+
if Capability.ATTENTION in self.capabilities:
|
|
391
|
+
self._ensure_eager_attention(model)
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
#: Families whose repo code targets an older transformers generate() loop.
|
|
395
|
+
_GENERATE_SHIM_FAMILIES = frozenset({"nemotron_h", "nemotron_h_omni"})
|
|
396
|
+
|
|
397
|
+
def _apply_family_shims(self, model, processor) -> None:
|
|
398
|
+
"""Per-family fixes so a loaded handle generates the way its authors intended.
|
|
399
|
+
|
|
400
|
+
NemotronH remote code (Nemotron 3 Nano, model card "tested on 4.48.3"):
|
|
401
|
+
|
|
402
|
+
* its ``prepare_inputs_for_generation`` builds the hybrid Mamba/attention
|
|
403
|
+
cache only when ``past_key_values is None``, but transformers >= 4.5x
|
|
404
|
+
pre-builds a ``DynamicCache`` for any class not named like a Mamba model
|
|
405
|
+
-> the repo code then warns "no NemotronHHybridDynamicCache provided"
|
|
406
|
+
and recomputes the whole sequence every step (1.9 tok/s on the 4B,
|
|
407
|
+
2026-08-21). Telling generate() the model has no default dynamic cache
|
|
408
|
+
restores the repo's own cache path.
|
|
409
|
+
* its chat template closes every turn with ``<|im_end|>`` (the
|
|
410
|
+
tokenizer's eos_token, id 11) while ``generation_config.eos_token_id``
|
|
411
|
+
is ``</s>`` (2): generate() never stops and pads to the cap with
|
|
412
|
+
``<|im_end|>\n`` pairs. Stopping on the tokenizer's EOS too fixes it.
|
|
413
|
+
|
|
414
|
+
Applied to the top-level model AND an embedded ``language_model`` (the
|
|
415
|
+
Omni wrapper generates through it). No-op for every other family.
|
|
416
|
+
"""
|
|
417
|
+
if self.spec.family in self._GENERATE_SHIM_FAMILIES:
|
|
418
|
+
import types
|
|
419
|
+
|
|
420
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
421
|
+
tok_eos = getattr(tok, "eos_token_id", None)
|
|
422
|
+
for target in (model, getattr(model, "language_model", None)):
|
|
423
|
+
if target is None:
|
|
424
|
+
continue
|
|
425
|
+
if hasattr(target, "_supports_default_dynamic_cache"):
|
|
426
|
+
target._supports_default_dynamic_cache = types.MethodType(lambda _self: False, target)
|
|
427
|
+
gen_cfg = getattr(target, "generation_config", None)
|
|
428
|
+
if gen_cfg is None or tok_eos is None:
|
|
429
|
+
continue
|
|
430
|
+
current = gen_cfg.eos_token_id
|
|
431
|
+
ids = list(current) if isinstance(current, (list, tuple)) else ([current] if current is not None else [])
|
|
432
|
+
if tok_eos not in ids:
|
|
433
|
+
gen_cfg.eos_token_id = ids + [int(tok_eos)]
|
|
434
|
+
logger.info("%s: generation stops on tokenizer eos %s as well (was %s)",
|
|
435
|
+
self.spec.key, tok_eos, current)
|
|
436
|
+
self._suppress_talker_audio(model)
|
|
437
|
+
|
|
438
|
+
def _suppress_talker_audio(self, model) -> None:
|
|
439
|
+
"""Never synthesize speech through an omni model's talker for a text benchmark.
|
|
440
|
+
|
|
441
|
+
Qwen3-Omni-Instruct (config ``enable_audio_output=True`` on the
|
|
442
|
+
published checkpoint) sets ``self.has_talker = True`` at load, and its
|
|
443
|
+
own ``generate()`` defaults ``return_audio`` to ``self.has_talker``
|
|
444
|
+
when the caller doesn't pass it. Every call then runs the full
|
|
445
|
+
text-to-speech pipeline and returns ``(thinker_result.sequences,
|
|
446
|
+
talker_wav)`` -- a 2-tuple whose first element is ``[batch, seq_len]``
|
|
447
|
+
(batch first, not the single flat token sequence every other
|
|
448
|
+
``model.generate()`` call site in this file assumes) -- instead of
|
|
449
|
+
just the thinker's text. Decoding that shape produced a batch-decode
|
|
450
|
+
(a Python list containing one string) that downstream code treated as
|
|
451
|
+
already-final text, so every generation surfaced as its own repr:
|
|
452
|
+
seen live on Qwen3-Omni-30B-A3B-Instruct/MMAU, every one of 128
|
|
453
|
+
cases, 2026-08-27 -- ``"['user\\n...\\nassistant\\nA']"``, not ``"A"``.
|
|
454
|
+
|
|
455
|
+
Nothing in this codebase's analysis reads a talker's audio output, so
|
|
456
|
+
request ``return_audio=False`` once here (read live off ``has_talker``
|
|
457
|
+
rather than a hard-coded family list, matching this module's
|
|
458
|
+
convention) instead of at all nine ``model.generate()`` call sites.
|
|
459
|
+
"""
|
|
460
|
+
if not callable(getattr(model, "generate", None)):
|
|
461
|
+
return
|
|
462
|
+
original_generate = model.generate
|
|
463
|
+
if getattr(original_generate, "_evalrx_talker_suppressed", False):
|
|
464
|
+
return # already wrapped (re-entrant load/wrap paths)
|
|
465
|
+
|
|
466
|
+
def _generate_without_talker(*args, **kwargs):
|
|
467
|
+
if getattr(model, "has_talker", False):
|
|
468
|
+
kwargs.setdefault("return_audio", False)
|
|
469
|
+
return original_generate(*args, **kwargs)
|
|
470
|
+
|
|
471
|
+
_generate_without_talker._evalrx_talker_suppressed = True
|
|
472
|
+
model.generate = _generate_without_talker
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _ensure_eager_attention(model) -> None:
|
|
476
|
+
"""Best-effort switch to eager attention so ``output_attentions`` is populated.
|
|
477
|
+
|
|
478
|
+
sdpa / flash_attention_2 silently return ``None`` attentions. Newer
|
|
479
|
+
transformers expose ``set_attn_implementation``; otherwise we set the config
|
|
480
|
+
flag and warn that a reload may be required for it to take effect.
|
|
481
|
+
"""
|
|
482
|
+
config = getattr(model, "config", None)
|
|
483
|
+
current = getattr(config, "_attn_implementation", None) if config is not None else None
|
|
484
|
+
if current == "eager":
|
|
485
|
+
return
|
|
486
|
+
if hasattr(model, "set_attn_implementation"):
|
|
487
|
+
try:
|
|
488
|
+
model.set_attn_implementation("eager")
|
|
489
|
+
return
|
|
490
|
+
except Exception: # pragma: no cover - falls through to the config flag
|
|
491
|
+
pass
|
|
492
|
+
if config is not None:
|
|
493
|
+
config._attn_implementation = "eager"
|
|
494
|
+
import warnings
|
|
495
|
+
|
|
496
|
+
msg = (
|
|
497
|
+
f"wrapped model used attn_implementation={current!r}; set it to 'eager' for "
|
|
498
|
+
"attention capture. If attentions come back empty, reload the model with "
|
|
499
|
+
"from_pretrained(..., attn_implementation='eager')."
|
|
500
|
+
)
|
|
501
|
+
logger.warning(msg)
|
|
502
|
+
warnings.warn(msg, stacklevel=2)
|
|
503
|
+
|
|
504
|
+
def unembed_weight(self):
|
|
505
|
+
"""The lm_head / unembedding weight ``(vocab, dim)`` for logit-lens."""
|
|
506
|
+
from evalrx.models._discover import get_unembed
|
|
507
|
+
|
|
508
|
+
model, _ = self._loaded
|
|
509
|
+
head = get_unembed(model)
|
|
510
|
+
return getattr(head, "weight", None)
|
|
511
|
+
|
|
512
|
+
def final_norm(self):
|
|
513
|
+
"""The final normalization module before the unembed (``None`` if not found).
|
|
514
|
+
|
|
515
|
+
RMSNorm-family models need ``lm_head(norm(h_i))``, not ``lm_head(h_i)``,
|
|
516
|
+
for faithful intermediate-layer readout (DeCo reference implementation).
|
|
517
|
+
"""
|
|
518
|
+
from evalrx.models._discover import get_final_norm
|
|
519
|
+
|
|
520
|
+
model, _ = self._loaded
|
|
521
|
+
return get_final_norm(model)
|
|
522
|
+
|
|
523
|
+
# -- lazy load -----------------------------------------------------
|
|
524
|
+
def load(self) -> None:
|
|
525
|
+
import time
|
|
526
|
+
|
|
527
|
+
import torch
|
|
528
|
+
import transformers
|
|
529
|
+
|
|
530
|
+
start = time.monotonic()
|
|
531
|
+
logger.info(
|
|
532
|
+
"loading %s from %s (backend=hf_local, dtype=%s, device=%s)",
|
|
533
|
+
self.spec.key, self.spec.hf_repo, self.runtime.dtype, self.runtime.device,
|
|
534
|
+
)
|
|
535
|
+
auto_cls = getattr(transformers, self.spec.auto_class)
|
|
536
|
+
proc_cls = getattr(transformers, self.spec.processor_class, transformers.AutoProcessor)
|
|
537
|
+
|
|
538
|
+
attn_impl = self.runtime.attn_impl
|
|
539
|
+
if (
|
|
540
|
+
attn_impl is None
|
|
541
|
+
and self.spec.eager_required_for_attn
|
|
542
|
+
and Capability.ATTENTION in self.capabilities
|
|
543
|
+
):
|
|
544
|
+
attn_impl = "eager" # sdpa/flash return None attentions
|
|
545
|
+
|
|
546
|
+
# Use `dtype` (the current transformers param; `torch_dtype` is deprecated).
|
|
547
|
+
kwargs: dict[str, Any] = dict(
|
|
548
|
+
dtype=getattr(torch, self.runtime.dtype),
|
|
549
|
+
trust_remote_code=self.spec.trust_remote_code,
|
|
550
|
+
)
|
|
551
|
+
if attn_impl:
|
|
552
|
+
kwargs["attn_implementation"] = attn_impl
|
|
553
|
+
|
|
554
|
+
device = self.runtime.device
|
|
555
|
+
if device in (None, "auto") or isinstance(device, dict):
|
|
556
|
+
# device_map path (multi-GPU / sharded) — needs accelerate
|
|
557
|
+
model = auto_cls.from_pretrained(
|
|
558
|
+
self.spec.hf_repo, device_map=device or "auto", **kwargs
|
|
559
|
+
)
|
|
560
|
+
else:
|
|
561
|
+
# explicit single device ("cuda" / "cuda:0" / "cpu") — no accelerate dependency
|
|
562
|
+
model = auto_cls.from_pretrained(self.spec.hf_repo, **kwargs).to(device)
|
|
563
|
+
model.eval()
|
|
564
|
+
processor = proc_cls.from_pretrained(
|
|
565
|
+
self.spec.hf_repo, trust_remote_code=self.spec.trust_remote_code
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Verify the declared TOOL_CALLS capability against the actual template.
|
|
569
|
+
if self.spec.tool_calling:
|
|
570
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
571
|
+
template = getattr(tok, "chat_template", None) or ""
|
|
572
|
+
if "tools" not in template:
|
|
573
|
+
import warnings
|
|
574
|
+
|
|
575
|
+
msg = (
|
|
576
|
+
f"{self.spec.key!r}: spec.tool_calling=True but the chat template has no "
|
|
577
|
+
"'tools' handling — tool-calling may not render. Verify the checkpoint."
|
|
578
|
+
)
|
|
579
|
+
logger.warning(msg)
|
|
580
|
+
warnings.warn(msg)
|
|
581
|
+
self._apply_family_shims(model, processor)
|
|
582
|
+
self._hf = (model, processor)
|
|
583
|
+
logger.info(
|
|
584
|
+
"loaded %s in %.1fs (capabilities=%s)",
|
|
585
|
+
self.spec.key, time.monotonic() - start, sorted(c.value for c in self.capabilities),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def _loaded(self):
|
|
590
|
+
if self._hf is None:
|
|
591
|
+
self.load()
|
|
592
|
+
return self._hf
|
|
593
|
+
|
|
594
|
+
@staticmethod
|
|
595
|
+
def _as_prompt(inputs: Any) -> str:
|
|
596
|
+
return inputs.prompt if isinstance(inputs, Inputs) else str(inputs)
|
|
597
|
+
|
|
598
|
+
def _render_text_prompt(self, tok: Any, prompt: str) -> str:
|
|
599
|
+
"""The text a TEXT-ONLY spec feeds the tokenizer.
|
|
600
|
+
|
|
601
|
+
With ``runtime.apply_chat_template`` the prompt becomes one user turn
|
|
602
|
+
rendered through the chat template, carrying ``spec.chat_template_kwargs``
|
|
603
|
+
(``enable_thinking=False`` for the reasoning checkpoints). Without it —
|
|
604
|
+
the default — the prompt is tokenised verbatim: completion mode, where
|
|
605
|
+
an instruct/thinking model writes its own ``<think>`` block and never
|
|
606
|
+
sees the thinking switch (Qwen3.5-2B ran 8192 tokens that way on a
|
|
607
|
+
causal-judgement item, 2026-08-21).
|
|
608
|
+
"""
|
|
609
|
+
if not self.runtime.apply_chat_template:
|
|
610
|
+
return prompt
|
|
611
|
+
if not getattr(tok, "chat_template", None) or not hasattr(tok, "apply_chat_template"):
|
|
612
|
+
logger.warning("%s: apply_chat_template requested but the tokenizer has no chat "
|
|
613
|
+
"template; tokenising the raw prompt", self.spec.key)
|
|
614
|
+
return prompt
|
|
615
|
+
return tok.apply_chat_template(
|
|
616
|
+
[{"role": "user", "content": prompt}],
|
|
617
|
+
add_generation_prompt=True,
|
|
618
|
+
tokenize=False,
|
|
619
|
+
**self.spec.chat_template_kwargs,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _encode(self, prompt: str):
|
|
623
|
+
import torch # noqa: F401
|
|
624
|
+
|
|
625
|
+
model, processor = self._loaded
|
|
626
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
627
|
+
text = self._render_text_prompt(tok, prompt)
|
|
628
|
+
if text is prompt:
|
|
629
|
+
enc = tok(text, return_tensors="pt")
|
|
630
|
+
else:
|
|
631
|
+
# a rendered template already carries BOS; a second one shifts every
|
|
632
|
+
# position the white-box analyzers read
|
|
633
|
+
enc = tok(text, return_tensors="pt", add_special_tokens=False)
|
|
634
|
+
device = next(model.parameters()).device
|
|
635
|
+
return {k: v.to(device) for k, v in enc.items()}
|
|
636
|
+
|
|
637
|
+
# -- interface -----------------------------------------------------
|
|
638
|
+
def generate(self, inputs: Any, **kwargs) -> str:
|
|
639
|
+
import torch
|
|
640
|
+
|
|
641
|
+
model, processor = self._loaded
|
|
642
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
643
|
+
if self.spec.needs_multimodal_encode:
|
|
644
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
645
|
+
else:
|
|
646
|
+
prompt = self._as_prompt(inputs)
|
|
647
|
+
enc = self._encode(prompt)
|
|
648
|
+
enc.pop("token_type_ids", None) # some VLM processors emit this; generate() rejects it
|
|
649
|
+
# "max_tokens" is the OpenAI-style name FixAgent's judge-proposed L2
|
|
650
|
+
# PipelineSpecs use (see fix_tools._safe_generation_kwargs and the
|
|
651
|
+
# _L2_PROMPT schema); transformers' generate() only recognises
|
|
652
|
+
# max_new_tokens and raises ValueError on an unrecognised kwarg
|
|
653
|
+
# rather than ignoring it, so every call in a judge-proposed
|
|
654
|
+
# pipeline silently failed (caught by run_pipeline's per-call
|
|
655
|
+
# try/except) and every case came back unscoreable. max_new_tokens
|
|
656
|
+
# wins if a caller passes both.
|
|
657
|
+
max_new = kwargs.pop("max_new_tokens", None)
|
|
658
|
+
if max_new is None:
|
|
659
|
+
max_new = kwargs.pop("max_tokens", self.runtime.max_new_tokens)
|
|
660
|
+
else:
|
|
661
|
+
kwargs.pop("max_tokens", None)
|
|
662
|
+
# PipelineSpec uses backend-neutral decoding controls. Translate the
|
|
663
|
+
# two controls that Hugging Face does not accept verbatim instead of
|
|
664
|
+
# letting run_pipeline swallow a ValueError and report 0 coverage.
|
|
665
|
+
temperature = kwargs.get("temperature")
|
|
666
|
+
if temperature is not None:
|
|
667
|
+
if float(temperature) <= 0.0:
|
|
668
|
+
kwargs.pop("temperature", None)
|
|
669
|
+
kwargs["do_sample"] = False
|
|
670
|
+
kwargs.pop("top_p", None)
|
|
671
|
+
kwargs.pop("top_k", None)
|
|
672
|
+
else:
|
|
673
|
+
kwargs.setdefault("do_sample", True)
|
|
674
|
+
stop = kwargs.pop("stop", None)
|
|
675
|
+
if stop:
|
|
676
|
+
kwargs["stop_strings"] = stop
|
|
677
|
+
kwargs["tokenizer"] = tok
|
|
678
|
+
with torch.no_grad():
|
|
679
|
+
out = model.generate(**enc, max_new_tokens=max_new, **kwargs)
|
|
680
|
+
new = _new_tokens(out[0], enc["input_ids"][0])
|
|
681
|
+
return tok.decode(new, skip_special_tokens=True)
|
|
682
|
+
|
|
683
|
+
def logprobs(
|
|
684
|
+
self, inputs: Any, max_new_tokens: int = 64, top_k: int = 5, **kwargs
|
|
685
|
+
) -> list[TokenLogprob]:
|
|
686
|
+
"""Per-output-token logprobs via greedy generate with output_scores."""
|
|
687
|
+
import torch
|
|
688
|
+
|
|
689
|
+
model, processor = self._loaded
|
|
690
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
691
|
+
if self.spec.needs_multimodal_encode:
|
|
692
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
693
|
+
else:
|
|
694
|
+
enc = self._encode(self._as_prompt(inputs))
|
|
695
|
+
enc.pop("token_type_ids", None)
|
|
696
|
+
with torch.no_grad():
|
|
697
|
+
out = model.generate(
|
|
698
|
+
**enc,
|
|
699
|
+
max_new_tokens=max_new_tokens,
|
|
700
|
+
do_sample=False,
|
|
701
|
+
output_scores=True,
|
|
702
|
+
return_dict_in_generate=True,
|
|
703
|
+
)
|
|
704
|
+
gen_ids = _new_tokens(out.sequences[0], enc["input_ids"][0]).tolist()
|
|
705
|
+
result: list[TokenLogprob] = []
|
|
706
|
+
for i, score in enumerate(out.scores):
|
|
707
|
+
lp = torch.log_softmax(score[0].float(), dim=-1)
|
|
708
|
+
tid = gen_ids[i]
|
|
709
|
+
topk = torch.topk(lp, min(top_k, lp.shape[-1]))
|
|
710
|
+
top = {tok.decode([int(j)]): float(v) for v, j in zip(topk.values, topk.indices)}
|
|
711
|
+
result.append(TokenLogprob(token=tok.decode([tid]), logprob=float(lp[tid]), top=top))
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
def _vcd_encodings(
|
|
715
|
+
self,
|
|
716
|
+
inputs: Any,
|
|
717
|
+
*,
|
|
718
|
+
noise_step: int,
|
|
719
|
+
noise_seed: int,
|
|
720
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
721
|
+
"""Build clean/noisy VCD inputs at the published tensor boundary.
|
|
722
|
+
|
|
723
|
+
The VCD release calls ``add_diffusion_noise`` *after* its image
|
|
724
|
+
processor: it perturbs the model's normalized image tensor, not an RGB
|
|
725
|
+
image that will subsequently be normalized again. Matching that
|
|
726
|
+
boundary is material; image-space noise has a different distribution.
|
|
727
|
+
A content-derived seed preserves paired-test reproducibility without
|
|
728
|
+
coupling one example's corruption to the iteration order of another.
|
|
729
|
+
"""
|
|
730
|
+
import hashlib
|
|
731
|
+
|
|
732
|
+
import torch
|
|
733
|
+
|
|
734
|
+
model, processor = self._loaded
|
|
735
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
736
|
+
enc.pop("token_type_ids", None)
|
|
737
|
+
pixels = enc.get("pixel_values")
|
|
738
|
+
if pixels is None:
|
|
739
|
+
raise ValueError(f"{self.spec.key}: VCD requires processor pixel_values")
|
|
740
|
+
step = max(0, min(999, int(noise_step)))
|
|
741
|
+
# This is vcd_utils/vcd_add_noise.py verbatim in numerical form.
|
|
742
|
+
betas = torch.sigmoid(torch.linspace(-6, 6, 1000, device=pixels.device))
|
|
743
|
+
betas = betas * (0.5e-2 - 1e-5) + 1e-5
|
|
744
|
+
alpha_bar = torch.cumprod(1 - betas, dim=0)[step].to(dtype=pixels.dtype)
|
|
745
|
+
digest = hashlib.sha256(pixels.detach().float().cpu().numpy().tobytes()).digest()
|
|
746
|
+
item_seed = (int(noise_seed) + int.from_bytes(digest[:8], "little")) % (2**63 - 1)
|
|
747
|
+
generator = torch.Generator(device=pixels.device).manual_seed(item_seed)
|
|
748
|
+
noise = torch.randn(
|
|
749
|
+
pixels.shape, device=pixels.device, dtype=pixels.dtype, generator=generator
|
|
750
|
+
)
|
|
751
|
+
noisy_enc = dict(enc)
|
|
752
|
+
noisy_enc["pixel_values"] = alpha_bar.sqrt() * pixels + (1 - alpha_bar).sqrt() * noise
|
|
753
|
+
return enc, noisy_enc
|
|
754
|
+
|
|
755
|
+
def _aad_encodings(self, inputs: Any) -> tuple[Any, dict[str, Any]]:
|
|
756
|
+
"""Build real-audio/silent-audio AAD inputs at the published boundary.
|
|
757
|
+
|
|
758
|
+
AAD's release zeroes the raw WAVEFORM (``np.zeros_like(audio)``) and
|
|
759
|
+
re-runs it through the same feature extractor, not the post-extraction
|
|
760
|
+
feature tensor directly -- a zeroed waveform's log-mel features are
|
|
761
|
+
not literally zero, so matching that boundary (not skipping straight
|
|
762
|
+
to zeroed ``input_features``) is material, same reasoning as VCD's
|
|
763
|
+
noise-after-processor boundary above.
|
|
764
|
+
"""
|
|
765
|
+
import numpy as np
|
|
766
|
+
|
|
767
|
+
from evalrx.core.case import Inputs
|
|
768
|
+
|
|
769
|
+
model, processor = self._loaded
|
|
770
|
+
audio = getattr(inputs, "audio", None)
|
|
771
|
+
if audio is None or isinstance(audio, (list, tuple)):
|
|
772
|
+
raise ValueError("AAD requires exactly one audio clip")
|
|
773
|
+
waveform = _resolve_audio(audio)
|
|
774
|
+
real_inputs = Inputs(
|
|
775
|
+
prompt=self._as_prompt(inputs),
|
|
776
|
+
image=getattr(inputs, "image", None),
|
|
777
|
+
audio=waveform,
|
|
778
|
+
video=getattr(inputs, "video", None),
|
|
779
|
+
)
|
|
780
|
+
silent_inputs = Inputs(
|
|
781
|
+
prompt=self._as_prompt(inputs),
|
|
782
|
+
image=getattr(inputs, "image", None),
|
|
783
|
+
audio=np.zeros_like(waveform),
|
|
784
|
+
video=getattr(inputs, "video", None),
|
|
785
|
+
)
|
|
786
|
+
enc, _ids, _tokens, _ttm = self._encode_vlm(real_inputs, model, processor)
|
|
787
|
+
enc.pop("token_type_ids", None)
|
|
788
|
+
silent_enc, _ids2, _tokens2, _ttm2 = self._encode_vlm(silent_inputs, model, processor)
|
|
789
|
+
silent_enc.pop("token_type_ids", None)
|
|
790
|
+
return enc, silent_enc
|
|
791
|
+
|
|
792
|
+
def generate_aad(self, inputs: Any, *, alpha: float = 0.5) -> str:
|
|
793
|
+
"""Run AAD (Hsu et al. 2025, arXiv:2506.07233): contrast real-audio
|
|
794
|
+
decoding against the same prompt with the audio waveform silenced,
|
|
795
|
+
at every step. See :mod:`evalrx.models.paper_methods.aad`.
|
|
796
|
+
"""
|
|
797
|
+
logger.debug("%s: generate_aad(alpha=%s)", self.spec.key, alpha)
|
|
798
|
+
if self.spec.audio is None:
|
|
799
|
+
raise ValueError(f"{self.spec.key}: AAD requires an audio-capable spec")
|
|
800
|
+
import torch
|
|
801
|
+
from transformers.generation.logits_process import LogitsProcessorList
|
|
802
|
+
|
|
803
|
+
from evalrx.models.paper_methods.aad import AADLogitsProcessor
|
|
804
|
+
|
|
805
|
+
model, processor = self._loaded
|
|
806
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
807
|
+
enc, silent_enc = self._aad_encodings(inputs)
|
|
808
|
+
processor_list = LogitsProcessorList(
|
|
809
|
+
[AADLogitsProcessor(model, silent_enc, alpha=alpha)]
|
|
810
|
+
)
|
|
811
|
+
with torch.no_grad():
|
|
812
|
+
out = model.generate(
|
|
813
|
+
**enc,
|
|
814
|
+
max_new_tokens=self.runtime.max_new_tokens,
|
|
815
|
+
do_sample=False,
|
|
816
|
+
use_cache=True,
|
|
817
|
+
logits_processor=processor_list,
|
|
818
|
+
)
|
|
819
|
+
return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
|
|
820
|
+
|
|
821
|
+
def generate_aad_baseline(self, inputs: Any) -> str:
|
|
822
|
+
"""Greedy-decode the real-audio arm alone -- the paired-comparison
|
|
823
|
+
control AAD's own eligibility gate looks for (mirrors
|
|
824
|
+
generate_vcd_baseline/generate_tcd_baseline's role)."""
|
|
825
|
+
logger.debug("%s: generate_aad_baseline()", self.spec.key)
|
|
826
|
+
if self.spec.audio is None:
|
|
827
|
+
raise ValueError(f"{self.spec.key}: AAD requires an audio-capable spec")
|
|
828
|
+
import torch
|
|
829
|
+
|
|
830
|
+
model, processor = self._loaded
|
|
831
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
832
|
+
enc, _silent_enc = self._aad_encodings(inputs)
|
|
833
|
+
with torch.no_grad():
|
|
834
|
+
out = model.generate(
|
|
835
|
+
**enc, max_new_tokens=self.runtime.max_new_tokens, do_sample=False, use_cache=True,
|
|
836
|
+
)
|
|
837
|
+
return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
|
|
838
|
+
|
|
839
|
+
def _vcd_next_token_logits(
|
|
840
|
+
self,
|
|
841
|
+
inputs: Any,
|
|
842
|
+
*,
|
|
843
|
+
noise_step: int,
|
|
844
|
+
noise_seed: int,
|
|
845
|
+
) -> tuple[Any, Any]:
|
|
846
|
+
"""Return clean/noisy next-token logits for the legacy binary route."""
|
|
847
|
+
import torch
|
|
848
|
+
|
|
849
|
+
model, _ = self._loaded
|
|
850
|
+
enc, noisy_enc = self._vcd_encodings(
|
|
851
|
+
inputs, noise_step=noise_step, noise_seed=noise_seed
|
|
852
|
+
)
|
|
853
|
+
with torch.no_grad():
|
|
854
|
+
clean = model(**enc).logits[0, -1]
|
|
855
|
+
noisy = model(**noisy_enc).logits[0, -1]
|
|
856
|
+
return clean, noisy
|
|
857
|
+
|
|
858
|
+
def generate_vcd(
|
|
859
|
+
self,
|
|
860
|
+
inputs: Any,
|
|
861
|
+
*,
|
|
862
|
+
alpha: float = 0.5,
|
|
863
|
+
beta: float = 0.1,
|
|
864
|
+
noise_step: int = 500,
|
|
865
|
+
noise_seed: int = 55,
|
|
866
|
+
) -> str:
|
|
867
|
+
"""Run VCD's released contrastive formula through every output token.
|
|
868
|
+
|
|
869
|
+
The source sampler uses temperature-one multinomial sampling. Here the
|
|
870
|
+
diffusion noise is seeded per image content, rather than a global loop
|
|
871
|
+
RNG, so a frozen selection/confirmation split remains reproducible if
|
|
872
|
+
case order changes. That seed policy is recorded as a specialization.
|
|
873
|
+
"""
|
|
874
|
+
logger.debug(
|
|
875
|
+
"%s: generate_vcd(alpha=%s, beta=%s, noise_step=%s, noise_seed=%s)",
|
|
876
|
+
self.spec.key, alpha, beta, noise_step, noise_seed,
|
|
877
|
+
)
|
|
878
|
+
if not self.spec.is_vlm:
|
|
879
|
+
raise ValueError("VCD visual contrast requires a VLM")
|
|
880
|
+
image = getattr(inputs, "image", None)
|
|
881
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
882
|
+
raise ValueError("VCD requires exactly one image")
|
|
883
|
+
import hashlib
|
|
884
|
+
|
|
885
|
+
import torch
|
|
886
|
+
from transformers.generation.logits_process import LogitsProcessorList
|
|
887
|
+
|
|
888
|
+
model, processor = self._loaded
|
|
889
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
890
|
+
clean_enc, noisy_enc = self._vcd_encodings(
|
|
891
|
+
inputs, noise_step=noise_step, noise_seed=noise_seed
|
|
892
|
+
)
|
|
893
|
+
from evalrx.models.paper_methods.vcd import VCDLogitsProcessor
|
|
894
|
+
|
|
895
|
+
# Match the release's temperature=1, top_p=1, no-top-k multinomial
|
|
896
|
+
# branch while preventing evaluation-order-dependent randomness.
|
|
897
|
+
fingerprint = hashlib.sha256(
|
|
898
|
+
clean_enc["pixel_values"].detach().float().cpu().numpy().tobytes()
|
|
899
|
+
).digest()
|
|
900
|
+
item_seed = (int(noise_seed) + int.from_bytes(fingerprint[:8], "little")) % (2**63 - 1)
|
|
901
|
+
processor_list = LogitsProcessorList(
|
|
902
|
+
[VCDLogitsProcessor(model, noisy_enc, alpha=alpha, beta=beta)]
|
|
903
|
+
)
|
|
904
|
+
cuda_devices = [clean_enc["input_ids"].device.index] if clean_enc["input_ids"].is_cuda else []
|
|
905
|
+
with torch.random.fork_rng(devices=cuda_devices), torch.no_grad():
|
|
906
|
+
torch.manual_seed(item_seed)
|
|
907
|
+
if clean_enc["input_ids"].is_cuda:
|
|
908
|
+
torch.cuda.manual_seed(item_seed)
|
|
909
|
+
out = model.generate(
|
|
910
|
+
**clean_enc,
|
|
911
|
+
max_new_tokens=self.runtime.max_new_tokens,
|
|
912
|
+
do_sample=True,
|
|
913
|
+
temperature=1.0,
|
|
914
|
+
top_p=1.0,
|
|
915
|
+
top_k=0,
|
|
916
|
+
use_cache=True,
|
|
917
|
+
logits_processor=processor_list,
|
|
918
|
+
)
|
|
919
|
+
return tok.decode(_new_tokens(out[0], clean_enc["input_ids"][0]), skip_special_tokens=True)
|
|
920
|
+
|
|
921
|
+
def generate_vcd_baseline(self, inputs: Any, *, noise_seed: int = 55) -> str:
|
|
922
|
+
"""Sample the clean VCD control with the candidate's per-image RNG.
|
|
923
|
+
|
|
924
|
+
VCD evaluates both arms with temperature-one multinomial sampling. A
|
|
925
|
+
greedy baseline paired with a sampled contrastive arm is not a valid
|
|
926
|
+
paper-method comparison, so the white-box runner calls this method
|
|
927
|
+
whenever it freezes the VCD candidate.
|
|
928
|
+
"""
|
|
929
|
+
logger.debug("%s: generate_vcd_baseline(noise_seed=%s)", self.spec.key, noise_seed)
|
|
930
|
+
if not self.spec.is_vlm:
|
|
931
|
+
raise ValueError("VCD clean control requires a VLM")
|
|
932
|
+
image = getattr(inputs, "image", None)
|
|
933
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
934
|
+
raise ValueError("VCD clean control requires one image")
|
|
935
|
+
import hashlib
|
|
936
|
+
|
|
937
|
+
import torch
|
|
938
|
+
|
|
939
|
+
model, processor = self._loaded
|
|
940
|
+
clean_enc, _ = self._vcd_encodings(inputs, noise_step=999, noise_seed=noise_seed)
|
|
941
|
+
fingerprint = hashlib.sha256(
|
|
942
|
+
clean_enc["pixel_values"].detach().float().cpu().numpy().tobytes()
|
|
943
|
+
).digest()
|
|
944
|
+
item_seed = (int(noise_seed) + int.from_bytes(fingerprint[:8], "little")) % (2**63 - 1)
|
|
945
|
+
cuda_devices = [clean_enc["input_ids"].device.index] if clean_enc["input_ids"].is_cuda else []
|
|
946
|
+
with torch.random.fork_rng(devices=cuda_devices), torch.no_grad():
|
|
947
|
+
torch.manual_seed(item_seed)
|
|
948
|
+
if clean_enc["input_ids"].is_cuda:
|
|
949
|
+
torch.cuda.manual_seed(item_seed)
|
|
950
|
+
out = model.generate(
|
|
951
|
+
**clean_enc,
|
|
952
|
+
max_new_tokens=self.runtime.max_new_tokens,
|
|
953
|
+
do_sample=True,
|
|
954
|
+
temperature=1.0,
|
|
955
|
+
top_p=1.0,
|
|
956
|
+
top_k=0,
|
|
957
|
+
use_cache=True,
|
|
958
|
+
)
|
|
959
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
960
|
+
return tok.decode(_new_tokens(out[0], clean_enc["input_ids"][0]), skip_special_tokens=True)
|
|
961
|
+
|
|
962
|
+
def generate_instruction_cd(
|
|
963
|
+
self,
|
|
964
|
+
inputs: Any,
|
|
965
|
+
*,
|
|
966
|
+
alpha: float = 1.0,
|
|
967
|
+
beta: float = 0.1,
|
|
968
|
+
qformer_mode: str = "normal",
|
|
969
|
+
disturbance: str = (
|
|
970
|
+
"You are a confused objects detector to provide a fuzzy overview "
|
|
971
|
+
"or impression of the image."
|
|
972
|
+
),
|
|
973
|
+
) -> str:
|
|
974
|
+
"""ICD for a binary visual-grounding decision.
|
|
975
|
+
|
|
976
|
+
ICD (Wang et al., ACL 2024) contrasts a normal forward pass with a
|
|
977
|
+
*disturbance-instruction* pass. For InstructBLIP, the disturbance
|
|
978
|
+
replaces only ``qformer_input_ids`` while the decoder prompt remains
|
|
979
|
+
intact, matching the released ``normal.json`` route; ``qformer_mode``
|
|
980
|
+
can also run the released disturbed-question variant. Decoder-only
|
|
981
|
+
VLMs such as Qwen have no Q-Former, so their text-prefix fallback is
|
|
982
|
+
explicitly architecture-adapted.
|
|
983
|
+
|
|
984
|
+
As with :meth:`generate_vcd`, this intentionally supports only a
|
|
985
|
+
one-token Yes/No task. Applying a first-token shortcut to free-form
|
|
986
|
+
generation would not implement ICD's token-by-token sampler.
|
|
987
|
+
"""
|
|
988
|
+
logger.debug(
|
|
989
|
+
"%s: generate_instruction_cd(alpha=%s, beta=%s, qformer_mode=%r)",
|
|
990
|
+
self.spec.key, alpha, beta, qformer_mode,
|
|
991
|
+
)
|
|
992
|
+
if not self.spec.is_vlm:
|
|
993
|
+
raise ValueError("instruction contrast requires a VLM")
|
|
994
|
+
image = getattr(inputs, "image", None)
|
|
995
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
996
|
+
raise ValueError("instruction contrast requires one image and a binary answer task")
|
|
997
|
+
model, processor = self._loaded
|
|
998
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
999
|
+
|
|
1000
|
+
def token_id(answer: str) -> int:
|
|
1001
|
+
for spelling in (" " + answer, answer):
|
|
1002
|
+
encoded = tok(spelling, add_special_tokens=False)["input_ids"]
|
|
1003
|
+
if len(encoded) == 1:
|
|
1004
|
+
return int(encoded[0])
|
|
1005
|
+
raise ValueError(
|
|
1006
|
+
f"{self.spec.key}: {answer!r} is not one token; ICD binary mode unavailable"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
if self.spec.model_type == "instructblip":
|
|
1010
|
+
import torch
|
|
1011
|
+
|
|
1012
|
+
if qformer_mode not in {"normal", "question"}:
|
|
1013
|
+
raise ValueError("qformer_mode must be 'normal' or 'question'")
|
|
1014
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
1015
|
+
clean_enc = dict(enc)
|
|
1016
|
+
dirty_enc = dict(enc)
|
|
1017
|
+
disturbed_qformer_prompt = str(disturbance)
|
|
1018
|
+
if qformer_mode == "question":
|
|
1019
|
+
disturbed_qformer_prompt += self._as_prompt(inputs)
|
|
1020
|
+
qformer = processor.qformer_tokenizer(
|
|
1021
|
+
disturbed_qformer_prompt, return_tensors="pt", padding="longest", truncation=True
|
|
1022
|
+
).to(next(model.parameters()).device)
|
|
1023
|
+
dirty_enc["qformer_input_ids"] = qformer["input_ids"]
|
|
1024
|
+
dirty_enc["qformer_attention_mask"] = qformer["attention_mask"]
|
|
1025
|
+
with torch.no_grad():
|
|
1026
|
+
clean = model(**clean_enc).logits[0, -1]
|
|
1027
|
+
disturbed = model(**dirty_enc).logits[0, -1]
|
|
1028
|
+
else:
|
|
1029
|
+
clean = self.forward(inputs, capture={Capability.LOGITS}).require(Capability.LOGITS)[-1]
|
|
1030
|
+
disturbed_inputs = Inputs(
|
|
1031
|
+
prompt=str(disturbance) + self._as_prompt(inputs), image=image
|
|
1032
|
+
)
|
|
1033
|
+
disturbed = self.forward(disturbed_inputs, capture={Capability.LOGITS}).require(
|
|
1034
|
+
Capability.LOGITS
|
|
1035
|
+
)[-1]
|
|
1036
|
+
ids = {answer: token_id(answer) for answer in ("Yes", "No")}
|
|
1037
|
+
clean_scores = {answer: float(clean[token].float()) for answer, token in ids.items()}
|
|
1038
|
+
disturbed_scores = {
|
|
1039
|
+
answer: float(disturbed[token].float()) for answer, token in ids.items()
|
|
1040
|
+
}
|
|
1041
|
+
cutoff = max(clean_scores.values()) + math.log(float(beta))
|
|
1042
|
+
scores = {
|
|
1043
|
+
answer: (1.0 + float(alpha)) * clean_scores[answer]
|
|
1044
|
+
- float(alpha) * disturbed_scores[answer]
|
|
1045
|
+
for answer in ids
|
|
1046
|
+
if clean_scores[answer] >= cutoff
|
|
1047
|
+
}
|
|
1048
|
+
return max(scores or clean_scores, key=(scores or clean_scores).get)
|
|
1049
|
+
|
|
1050
|
+
def generate_detector_grounded_presence(
|
|
1051
|
+
self,
|
|
1052
|
+
inputs: Any,
|
|
1053
|
+
*,
|
|
1054
|
+
baseline_answer: str,
|
|
1055
|
+
objects: list[str] | tuple[str, ...],
|
|
1056
|
+
detector_threshold: float = 0.25,
|
|
1057
|
+
detector_engine: Any | None = None,
|
|
1058
|
+
) -> str:
|
|
1059
|
+
"""Override a negative binary answer only with calibrated detector evidence.
|
|
1060
|
+
|
|
1061
|
+
The object allowlist and score threshold are explicit experiment payload,
|
|
1062
|
+
not inferred from the case label or metadata. Grounding DINO is queried
|
|
1063
|
+
only when the baseline is negative and the prompt names an allowlisted
|
|
1064
|
+
object, keeping the intervention narrow and deterministic.
|
|
1065
|
+
"""
|
|
1066
|
+
import re
|
|
1067
|
+
|
|
1068
|
+
if re.search(r"\byes\b", str(baseline_answer), flags=re.IGNORECASE):
|
|
1069
|
+
return baseline_answer
|
|
1070
|
+
if not re.search(r"\bno\b", str(baseline_answer), flags=re.IGNORECASE):
|
|
1071
|
+
return baseline_answer
|
|
1072
|
+
|
|
1073
|
+
prompt = self._as_prompt(inputs)
|
|
1074
|
+
match = re.search(
|
|
1075
|
+
r"\b(?:is|are) there (?:an?\s+|any\s+)?(.+?)\s+in (?:the|this) image\b",
|
|
1076
|
+
prompt,
|
|
1077
|
+
flags=re.IGNORECASE,
|
|
1078
|
+
)
|
|
1079
|
+
if match is None:
|
|
1080
|
+
return baseline_answer
|
|
1081
|
+
query = re.sub(r"\s+", " ", match.group(1).strip().lower())
|
|
1082
|
+
allowed = {str(name).strip().lower() for name in objects}
|
|
1083
|
+
if query not in allowed:
|
|
1084
|
+
return baseline_answer
|
|
1085
|
+
|
|
1086
|
+
image = getattr(inputs, "image", None)
|
|
1087
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
1088
|
+
return baseline_answer
|
|
1089
|
+
engine = detector_engine
|
|
1090
|
+
if engine is None:
|
|
1091
|
+
model, _ = self._loaded
|
|
1092
|
+
device = str(next(model.parameters()).device)
|
|
1093
|
+
engine = self._detect_engines.get(device)
|
|
1094
|
+
if engine is None:
|
|
1095
|
+
from evalrx.models.tools.perception import default_detect_engine
|
|
1096
|
+
|
|
1097
|
+
engine = default_detect_engine(
|
|
1098
|
+
device=device, threshold=0.01, text_threshold=0.01
|
|
1099
|
+
)
|
|
1100
|
+
self._detect_engines[device] = engine
|
|
1101
|
+
detections = engine(_resolve_image(image), query)
|
|
1102
|
+
score = max((float(item.get("score", 0.0)) for item in detections), default=0.0)
|
|
1103
|
+
return "Yes" if score >= float(detector_threshold) else baseline_answer
|
|
1104
|
+
|
|
1105
|
+
def generate_clap_grounded_presence(
|
|
1106
|
+
self,
|
|
1107
|
+
inputs: Any,
|
|
1108
|
+
*,
|
|
1109
|
+
baseline_answer: str,
|
|
1110
|
+
negative_threshold: float = -0.05,
|
|
1111
|
+
positive_threshold: float = 0.275,
|
|
1112
|
+
model_id: str = "laion/clap-htsat-unfused",
|
|
1113
|
+
similarity_engine: Any | None = None,
|
|
1114
|
+
) -> str:
|
|
1115
|
+
"""Gate binary sound-presence answers with calibrated CLAP evidence."""
|
|
1116
|
+
import re
|
|
1117
|
+
|
|
1118
|
+
baseline = str(baseline_answer)
|
|
1119
|
+
is_yes = re.search(r"\byes\b", baseline, flags=re.IGNORECASE) is not None
|
|
1120
|
+
is_no = re.search(r"\bno\b", baseline, flags=re.IGNORECASE) is not None
|
|
1121
|
+
if not (is_yes or is_no):
|
|
1122
|
+
return baseline_answer
|
|
1123
|
+
match = re.search(
|
|
1124
|
+
r"\bsound of (?:an?\s+)?(.+?)(?:\s+in (?:the|this) audio|\?)",
|
|
1125
|
+
self._as_prompt(inputs),
|
|
1126
|
+
flags=re.IGNORECASE,
|
|
1127
|
+
)
|
|
1128
|
+
if match is None:
|
|
1129
|
+
return baseline_answer
|
|
1130
|
+
query = re.sub(r"\s+", " ", match.group(1).strip().lower())
|
|
1131
|
+
audio = getattr(inputs, "audio", None)
|
|
1132
|
+
if audio is None or isinstance(audio, (list, tuple)):
|
|
1133
|
+
return baseline_answer
|
|
1134
|
+
|
|
1135
|
+
engine = similarity_engine
|
|
1136
|
+
if engine is None:
|
|
1137
|
+
model, _ = self._loaded
|
|
1138
|
+
device = str(next(model.parameters()).device)
|
|
1139
|
+
key = (str(model_id), device)
|
|
1140
|
+
engine = self._audio_text_engines.get(key)
|
|
1141
|
+
if engine is None:
|
|
1142
|
+
import torch
|
|
1143
|
+
from scipy.signal import resample_poly
|
|
1144
|
+
from transformers import ClapModel, ClapProcessor
|
|
1145
|
+
|
|
1146
|
+
processor = ClapProcessor.from_pretrained(model_id)
|
|
1147
|
+
clap = ClapModel.from_pretrained(model_id).to(device).eval()
|
|
1148
|
+
|
|
1149
|
+
def engine(waveform: Any, text: str) -> float:
|
|
1150
|
+
samples = resample_poly(waveform, 3, 1).astype("float32")
|
|
1151
|
+
encoded = processor(
|
|
1152
|
+
text=["the sound of " + text], audio=[samples],
|
|
1153
|
+
sampling_rate=48000, return_tensors="pt", padding=True,
|
|
1154
|
+
)
|
|
1155
|
+
encoded = {name: value.to(device) for name, value in encoded.items()}
|
|
1156
|
+
with torch.no_grad():
|
|
1157
|
+
audio_features = clap.get_audio_features(
|
|
1158
|
+
input_features=encoded["input_features"],
|
|
1159
|
+
is_longer=encoded.get("is_longer"),
|
|
1160
|
+
).pooler_output
|
|
1161
|
+
text_features = clap.get_text_features(
|
|
1162
|
+
input_ids=encoded["input_ids"],
|
|
1163
|
+
attention_mask=encoded["attention_mask"],
|
|
1164
|
+
).pooler_output
|
|
1165
|
+
return float(torch.nn.functional.cosine_similarity(
|
|
1166
|
+
audio_features, text_features
|
|
1167
|
+
)[0])
|
|
1168
|
+
|
|
1169
|
+
self._audio_text_engines[key] = engine
|
|
1170
|
+
score = float(engine(_resolve_audio(audio), query))
|
|
1171
|
+
if is_yes and score < float(negative_threshold):
|
|
1172
|
+
return "No"
|
|
1173
|
+
if is_no and score >= float(positive_threshold):
|
|
1174
|
+
return "Yes"
|
|
1175
|
+
return baseline_answer
|
|
1176
|
+
|
|
1177
|
+
def generate_audio_api_specialist(
|
|
1178
|
+
self,
|
|
1179
|
+
inputs: Any,
|
|
1180
|
+
*,
|
|
1181
|
+
baseline_answer: str,
|
|
1182
|
+
model_id: str = "gemini-3.7-flash",
|
|
1183
|
+
allowed_disagreements: list[str] | tuple[str, ...] | None = None,
|
|
1184
|
+
allowed_disagreements_by_route: dict[str, list[str]] | None = None,
|
|
1185
|
+
specialist_engine: Any | None = None,
|
|
1186
|
+
) -> str:
|
|
1187
|
+
"""Answer an audio question with a frozen external multimodal specialist."""
|
|
1188
|
+
import re
|
|
1189
|
+
|
|
1190
|
+
prompt = self._as_prompt(inputs)
|
|
1191
|
+
audio = getattr(inputs, "audio", None)
|
|
1192
|
+
if audio is None or isinstance(audio, (list, tuple)):
|
|
1193
|
+
return baseline_answer
|
|
1194
|
+
engine = specialist_engine or self._audio_api_specialist_engines.get(str(model_id))
|
|
1195
|
+
if engine is None:
|
|
1196
|
+
from evalrx.models.blackbox.gemini import GeminiModel
|
|
1197
|
+
|
|
1198
|
+
specialist = GeminiModel(model_id=str(model_id))
|
|
1199
|
+
|
|
1200
|
+
def engine(specialist_inputs: Inputs) -> str:
|
|
1201
|
+
turn = specialist.chat(
|
|
1202
|
+
[{
|
|
1203
|
+
"role": "user",
|
|
1204
|
+
"content": [
|
|
1205
|
+
{"type": "audio", "audio": specialist_inputs.audio},
|
|
1206
|
+
{"type": "text", "text": specialist_inputs.prompt},
|
|
1207
|
+
],
|
|
1208
|
+
}]
|
|
1209
|
+
)
|
|
1210
|
+
return turn.text
|
|
1211
|
+
|
|
1212
|
+
self._audio_api_specialist_engines[str(model_id)] = engine
|
|
1213
|
+
answer = str(engine(Inputs(prompt=prompt, audio=audio)))
|
|
1214
|
+
route: str | None = None
|
|
1215
|
+
if allowed_disagreements_by_route is not None:
|
|
1216
|
+
question = prompt.split("\n", 1)[0]
|
|
1217
|
+
if re.search(
|
|
1218
|
+
r"\b(?:conversation|speakers?|utterance|words?|sentence|phoneme|"
|
|
1219
|
+
r"sarcas\w*|dialogue|people talking|emotional?\s+state|emotion of)\b",
|
|
1220
|
+
question,
|
|
1221
|
+
flags=re.IGNORECASE,
|
|
1222
|
+
):
|
|
1223
|
+
route = "speech"
|
|
1224
|
+
elif re.search(
|
|
1225
|
+
r"\b(?:music|musical|melody|chord|guitar|drum|bass|synth|instrument|"
|
|
1226
|
+
r"rhythm|tempo|key signature|tonic|chant|singer|vocal|genre|harmonica|"
|
|
1227
|
+
r"percussive|song|vocals|time signature|audio quality|sound texture|"
|
|
1228
|
+
r"texture of the sound|e-guitar)\b",
|
|
1229
|
+
question,
|
|
1230
|
+
flags=re.IGNORECASE,
|
|
1231
|
+
):
|
|
1232
|
+
route = "music"
|
|
1233
|
+
else:
|
|
1234
|
+
route = "sound"
|
|
1235
|
+
allowed_disagreements = allowed_disagreements_by_route.get(route, [])
|
|
1236
|
+
if allowed_disagreements is not None:
|
|
1237
|
+
baseline_letters = re.findall(r"\b([A-D])\b", str(baseline_answer).upper())
|
|
1238
|
+
specialist_letters = re.findall(r"\b([A-D])\b", answer.upper())
|
|
1239
|
+
if not baseline_letters or not specialist_letters:
|
|
1240
|
+
return baseline_answer
|
|
1241
|
+
pair = baseline_letters[0] + specialist_letters[0]
|
|
1242
|
+
if pair not in {str(item).upper() for item in allowed_disagreements}:
|
|
1243
|
+
return baseline_answer
|
|
1244
|
+
return answer
|
|
1245
|
+
|
|
1246
|
+
def generate_noncolor_spatial_specialist(
|
|
1247
|
+
self,
|
|
1248
|
+
inputs: Any,
|
|
1249
|
+
*,
|
|
1250
|
+
baseline_answer: str,
|
|
1251
|
+
model_id: str = "qwen2.5-vl-7b-instruct",
|
|
1252
|
+
specialist_engine: Any | None = None,
|
|
1253
|
+
) -> str:
|
|
1254
|
+
"""Route non-color spatial questions to a frozen vision specialist."""
|
|
1255
|
+
prompt = self._as_prompt(inputs)
|
|
1256
|
+
lowered = prompt.lower()
|
|
1257
|
+
if "shape" not in lowered and "color" in lowered:
|
|
1258
|
+
return baseline_answer
|
|
1259
|
+
image = getattr(inputs, "image", None)
|
|
1260
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
1261
|
+
return baseline_answer
|
|
1262
|
+
|
|
1263
|
+
engine = specialist_engine or self._vision_specialist_engines.get(str(model_id))
|
|
1264
|
+
if engine is None:
|
|
1265
|
+
import gc
|
|
1266
|
+
|
|
1267
|
+
import torch
|
|
1268
|
+
|
|
1269
|
+
from evalrx.specs import get_spec
|
|
1270
|
+
|
|
1271
|
+
subject, _ = self._loaded
|
|
1272
|
+
device = str(next(subject.parameters()).device)
|
|
1273
|
+
subject.to("cpu")
|
|
1274
|
+
gc.collect()
|
|
1275
|
+
torch.cuda.empty_cache()
|
|
1276
|
+
specialist = HFLocalModel(
|
|
1277
|
+
get_spec(model_id),
|
|
1278
|
+
RuntimeConfig(
|
|
1279
|
+
device=device,
|
|
1280
|
+
dtype=self.runtime.dtype,
|
|
1281
|
+
attn_impl=self.runtime.attn_impl,
|
|
1282
|
+
max_new_tokens=self.runtime.max_new_tokens,
|
|
1283
|
+
apply_chat_template=True,
|
|
1284
|
+
),
|
|
1285
|
+
)
|
|
1286
|
+
specialist.load()
|
|
1287
|
+
engine = specialist.generate
|
|
1288
|
+
self._vision_specialist_engines[str(model_id)] = engine
|
|
1289
|
+
return str(engine(Inputs(prompt=prompt, image=image)))
|
|
1290
|
+
|
|
1291
|
+
def generate_chart_vision_specialist(
|
|
1292
|
+
self,
|
|
1293
|
+
inputs: Any,
|
|
1294
|
+
*,
|
|
1295
|
+
baseline_answer: str,
|
|
1296
|
+
model_id: str = "qwen2.5-vl-7b-instruct",
|
|
1297
|
+
specialist_engine: Any | None = None,
|
|
1298
|
+
) -> str:
|
|
1299
|
+
"""Answer a chart question with a frozen, independently loaded VLM."""
|
|
1300
|
+
prompt = self._as_prompt(inputs)
|
|
1301
|
+
image = getattr(inputs, "image", None)
|
|
1302
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
1303
|
+
return baseline_answer
|
|
1304
|
+
engine = specialist_engine or self._vision_specialist_engines.get(str(model_id))
|
|
1305
|
+
if engine is None:
|
|
1306
|
+
import gc
|
|
1307
|
+
|
|
1308
|
+
import torch
|
|
1309
|
+
|
|
1310
|
+
from evalrx.specs import get_spec
|
|
1311
|
+
|
|
1312
|
+
subject, _ = self._loaded
|
|
1313
|
+
device = str(next(subject.parameters()).device)
|
|
1314
|
+
subject.to("cpu")
|
|
1315
|
+
gc.collect()
|
|
1316
|
+
torch.cuda.empty_cache()
|
|
1317
|
+
specialist = HFLocalModel(
|
|
1318
|
+
get_spec(model_id),
|
|
1319
|
+
RuntimeConfig(
|
|
1320
|
+
device=device,
|
|
1321
|
+
dtype=self.runtime.dtype,
|
|
1322
|
+
attn_impl=self.runtime.attn_impl,
|
|
1323
|
+
max_new_tokens=self.runtime.max_new_tokens,
|
|
1324
|
+
apply_chat_template=True,
|
|
1325
|
+
),
|
|
1326
|
+
)
|
|
1327
|
+
specialist.load()
|
|
1328
|
+
engine = specialist.generate
|
|
1329
|
+
self._vision_specialist_engines[str(model_id)] = engine
|
|
1330
|
+
return str(engine(Inputs(prompt=prompt, image=image)))
|
|
1331
|
+
|
|
1332
|
+
def generate_vision_api_specialist(
|
|
1333
|
+
self,
|
|
1334
|
+
inputs: Any,
|
|
1335
|
+
*,
|
|
1336
|
+
baseline_answer: str,
|
|
1337
|
+
model_id: str = "gemini-3.7-flash",
|
|
1338
|
+
specialist_engine: Any | None = None,
|
|
1339
|
+
) -> str:
|
|
1340
|
+
"""Answer an image question with a frozen external multimodal specialist."""
|
|
1341
|
+
import re
|
|
1342
|
+
|
|
1343
|
+
prompt = self._as_prompt(inputs)
|
|
1344
|
+
image = getattr(inputs, "image", None)
|
|
1345
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
1346
|
+
return baseline_answer
|
|
1347
|
+
engine = specialist_engine or self._vision_api_specialist_engines.get(str(model_id))
|
|
1348
|
+
if engine is None:
|
|
1349
|
+
from evalrx.models.blackbox.gemini import GeminiModel
|
|
1350
|
+
|
|
1351
|
+
specialist = GeminiModel(model_id=str(model_id))
|
|
1352
|
+
engine = specialist.generate
|
|
1353
|
+
self._vision_api_specialist_engines[str(model_id)] = engine
|
|
1354
|
+
strict_prompt = prompt + (
|
|
1355
|
+
"\nReturn only the final short answer with no explanation, units, or formatting."
|
|
1356
|
+
)
|
|
1357
|
+
answer = str(engine(Inputs(prompt=strict_prompt, image=image))).strip()
|
|
1358
|
+
ratio = re.fullmatch(r"\s*([-+]?\d+(?:\.\d+)?)\s*:\s*([-+]?\d+(?:\.\d+)?)\s*", answer)
|
|
1359
|
+
if ratio is not None and float(ratio.group(2)) != 0:
|
|
1360
|
+
return str(float(ratio.group(1)) / float(ratio.group(2)))
|
|
1361
|
+
return answer
|
|
1362
|
+
|
|
1363
|
+
def generate_vicrop(self, inputs: Any, *, layer: int | float = 14) -> str:
|
|
1364
|
+
"""Run the architecture-native LLaVA ViCrop paper executor."""
|
|
1365
|
+
logger.debug("%s: generate_vicrop(layer=%s)", self.spec.key, layer)
|
|
1366
|
+
if self.spec.model_type != "llava":
|
|
1367
|
+
raise ValueError(f"{self.spec.key}: ViCrop is only native on the LLaVA executor")
|
|
1368
|
+
from evalrx.models.paper_methods.vicrop import generate
|
|
1369
|
+
|
|
1370
|
+
return generate(self, inputs, layer=layer)
|
|
1371
|
+
|
|
1372
|
+
def generate_opera_binary(
|
|
1373
|
+
self,
|
|
1374
|
+
inputs: Any,
|
|
1375
|
+
*,
|
|
1376
|
+
num_attn_candidates: int = 5,
|
|
1377
|
+
penalty_weight: float = 1.0,
|
|
1378
|
+
) -> str:
|
|
1379
|
+
"""Run OPERA's first-token over-trust penalty for a binary VQA task.
|
|
1380
|
+
|
|
1381
|
+
OPERA scores each likely continuation by the image attention of the
|
|
1382
|
+
*candidate token* and subtracts ``-image_attention`` from its logit.
|
|
1383
|
+
This is its published early-response penalty verbatim. POPE evaluates
|
|
1384
|
+
a single Yes/No token, therefore the later multi-token rollback branch
|
|
1385
|
+
is intentionally out of scope and this method must stay labelled a
|
|
1386
|
+
binary specialization.
|
|
1387
|
+
"""
|
|
1388
|
+
import torch
|
|
1389
|
+
|
|
1390
|
+
logger.debug(
|
|
1391
|
+
"%s: generate_opera_binary(num_attn_candidates=%s, penalty_weight=%s)",
|
|
1392
|
+
self.spec.key, num_attn_candidates, penalty_weight,
|
|
1393
|
+
)
|
|
1394
|
+
if self.spec.model_type != "llava":
|
|
1395
|
+
raise ValueError(f"{self.spec.key}: OPERA binary route is only native on LLaVA")
|
|
1396
|
+
if int(num_attn_candidates) < 1:
|
|
1397
|
+
raise ValueError("num_attn_candidates must be positive")
|
|
1398
|
+
model, processor = self._loaded
|
|
1399
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
1400
|
+
enc.pop("token_type_ids", None)
|
|
1401
|
+
image_id = getattr(model.config, "image_token_index", None)
|
|
1402
|
+
if image_id is None:
|
|
1403
|
+
raise ValueError(f"{self.spec.key}: OPERA requires config.image_token_index")
|
|
1404
|
+
image_positions = (enc["input_ids"][0] == int(image_id)).nonzero().flatten()
|
|
1405
|
+
if image_positions.numel() == 0 or not bool(
|
|
1406
|
+
(image_positions[1:] == image_positions[:-1] + 1).all()
|
|
1407
|
+
):
|
|
1408
|
+
raise ValueError(f"{self.spec.key}: OPERA requires one contiguous image-token span")
|
|
1409
|
+
|
|
1410
|
+
with torch.no_grad():
|
|
1411
|
+
prefill = model(**enc, return_dict=True, output_attentions=True, use_cache=False)
|
|
1412
|
+
if not getattr(prefill, "attentions", None):
|
|
1413
|
+
raise ValueError(f"{self.spec.key}: OPERA requires eager self-attention outputs")
|
|
1414
|
+
raw_logits = prefill.logits[:, -1, :]
|
|
1415
|
+
k = min(int(num_attn_candidates), int(raw_logits.shape[-1]))
|
|
1416
|
+
candidate_scores, candidate_tokens = torch.topk(raw_logits, k, dim=-1, largest=True, sorted=True)
|
|
1417
|
+
adjusted_scores = candidate_scores.clone()
|
|
1418
|
+
for candidate_index in range(k):
|
|
1419
|
+
candidate_enc = dict(enc)
|
|
1420
|
+
candidate_enc["input_ids"] = torch.cat(
|
|
1421
|
+
(enc["input_ids"], candidate_tokens[:, candidate_index : candidate_index + 1]), dim=1
|
|
1422
|
+
)
|
|
1423
|
+
if "attention_mask" in candidate_enc:
|
|
1424
|
+
candidate_enc["attention_mask"] = torch.cat(
|
|
1425
|
+
(enc["attention_mask"], torch.ones_like(enc["attention_mask"][:, :1])), dim=1
|
|
1426
|
+
)
|
|
1427
|
+
with torch.no_grad():
|
|
1428
|
+
candidate_output = model(
|
|
1429
|
+
**candidate_enc, return_dict=True, output_attentions=True, use_cache=False
|
|
1430
|
+
)
|
|
1431
|
+
attentions = getattr(candidate_output, "attentions", None)
|
|
1432
|
+
if not attentions:
|
|
1433
|
+
raise ValueError(f"{self.spec.key}: OPERA candidate forward returned no attentions")
|
|
1434
|
+
# Reference OPERA maximises heads then sums the candidate's image
|
|
1435
|
+
# attention. With one beam, selecting the adjusted top candidate
|
|
1436
|
+
# is identical to its first beam-search step.
|
|
1437
|
+
last_attention = attentions[-1].amax(dim=1)[:, -1, :]
|
|
1438
|
+
image_attention = last_attention[:, image_positions].sum(dim=-1)
|
|
1439
|
+
adjusted_scores[:, candidate_index] += float(penalty_weight) * image_attention
|
|
1440
|
+
selected = candidate_tokens.gather(1, adjusted_scores.argmax(dim=-1, keepdim=True)).squeeze(1)
|
|
1441
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1442
|
+
return tok.decode([int(selected[0])], skip_special_tokens=True)
|
|
1443
|
+
|
|
1444
|
+
def generate_ifcd(
|
|
1445
|
+
self,
|
|
1446
|
+
inputs: Any,
|
|
1447
|
+
*,
|
|
1448
|
+
alpha: float = 0.1,
|
|
1449
|
+
beta: float = 0.1,
|
|
1450
|
+
edit_strength: float = 0.5,
|
|
1451
|
+
top_layers: int = 15,
|
|
1452
|
+
max_new_tokens: int | None = None,
|
|
1453
|
+
) -> str:
|
|
1454
|
+
"""Run an explicitly adapted TruthX-backed IFCD decoder on LLaVA.
|
|
1455
|
+
|
|
1456
|
+
The checkpoint path is a required runtime artifact, rather than an
|
|
1457
|
+
implicit download: its provenance controls whether an experiment can
|
|
1458
|
+
compare itself with IFCD's MSCOCO-trained editor. This adapter uses
|
|
1459
|
+
modern HF output hooks, so even a matching artifact remains labelled
|
|
1460
|
+
adapted until its pre-``o_proj`` boundary is ported.
|
|
1461
|
+
"""
|
|
1462
|
+
import torch
|
|
1463
|
+
from transformers.generation.logits_process import LogitsProcessorList
|
|
1464
|
+
|
|
1465
|
+
logger.debug(
|
|
1466
|
+
"%s: generate_ifcd(alpha=%s, beta=%s, edit_strength=%s, top_layers=%s)",
|
|
1467
|
+
self.spec.key, alpha, beta, edit_strength, top_layers,
|
|
1468
|
+
)
|
|
1469
|
+
if self.spec.model_type != "llava":
|
|
1470
|
+
raise ValueError(f"{self.spec.key}: IFCD is only wired for LLaVA's Vicuna decoder")
|
|
1471
|
+
checkpoint = self.runtime.engine_kwargs.get("ifcd_checkpoint")
|
|
1472
|
+
if not checkpoint or not Path(str(checkpoint)).is_file():
|
|
1473
|
+
raise ValueError("IFCD requires runtime.engine_kwargs['ifcd_checkpoint']")
|
|
1474
|
+
model, processor = self._loaded
|
|
1475
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
1476
|
+
enc.pop("token_type_ids", None)
|
|
1477
|
+
key = f"{Path(str(checkpoint)).resolve()}:{int(top_layers)}"
|
|
1478
|
+
editor = self._ifcd_editors.get(key)
|
|
1479
|
+
if editor is None:
|
|
1480
|
+
from evalrx.models.paper_methods.ifcd import TruthXEditor
|
|
1481
|
+
|
|
1482
|
+
hidden_size = int(getattr(model.config.text_config, "hidden_size", 0) or model.config.hidden_size)
|
|
1483
|
+
editor = TruthXEditor(checkpoint, hidden_size=hidden_size, top_layers=top_layers)
|
|
1484
|
+
self._ifcd_editors[key] = editor
|
|
1485
|
+
from evalrx.models.paper_methods.ifcd import IFCDLogitsProcessor, truthx_editing
|
|
1486
|
+
|
|
1487
|
+
language_model = getattr(model, "language_model", model)
|
|
1488
|
+
editor.strength = float(edit_strength)
|
|
1489
|
+
processor_list = LogitsProcessorList(
|
|
1490
|
+
[
|
|
1491
|
+
IFCDLogitsProcessor(
|
|
1492
|
+
model,
|
|
1493
|
+
dict(enc),
|
|
1494
|
+
editor,
|
|
1495
|
+
alpha=alpha,
|
|
1496
|
+
beta=beta,
|
|
1497
|
+
edit_strength=edit_strength,
|
|
1498
|
+
)
|
|
1499
|
+
]
|
|
1500
|
+
)
|
|
1501
|
+
with truthx_editing(language_model, editor), torch.no_grad():
|
|
1502
|
+
out = model.generate(
|
|
1503
|
+
**enc,
|
|
1504
|
+
max_new_tokens=max_new_tokens or self.runtime.max_new_tokens,
|
|
1505
|
+
do_sample=False,
|
|
1506
|
+
logits_processor=processor_list,
|
|
1507
|
+
)
|
|
1508
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1509
|
+
return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
|
|
1510
|
+
|
|
1511
|
+
def generate_tcd(
|
|
1512
|
+
self,
|
|
1513
|
+
inputs: Any,
|
|
1514
|
+
*,
|
|
1515
|
+
max_new_tokens: int | None = None,
|
|
1516
|
+
hyperparams: "Any | None" = None,
|
|
1517
|
+
) -> str:
|
|
1518
|
+
"""Run Temporal Contrastive Decoding (Li et al. 2026, arXiv:2604.15383).
|
|
1519
|
+
|
|
1520
|
+
Cannot be built as a ``model.generate(logits_processor=[...])`` bolt-on
|
|
1521
|
+
the way VCD/IFCD/PAI are: a :class:`~transformers.LogitsProcessor` only
|
|
1522
|
+
ever sees ``(input_ids, scores)``, never the forward pass's attention
|
|
1523
|
+
weights, and TCD's gate (Eq. 8) needs the CURRENT step's decoder
|
|
1524
|
+
attention to audio tokens. So this runs its own greedy decode loop,
|
|
1525
|
+
holding two KV caches (original audio / Hann-blurred slow-path audio,
|
|
1526
|
+
Eq. 1) and calling the model directly each step -- exactly what VCD's
|
|
1527
|
+
processor already does for its single contrastive branch, just applied
|
|
1528
|
+
to both branches here, with ``output_attentions=True`` on the original
|
|
1529
|
+
branch to get the audio-attention ratio for free from the same forward
|
|
1530
|
+
(matches the paper's own reported ~1.00x decode-step overhead, Table 7:
|
|
1531
|
+
the attention weights are already computed internally, not an extra
|
|
1532
|
+
pass).
|
|
1533
|
+
|
|
1534
|
+
The per-example blur window and update scale (Eq. 5-6) are derived
|
|
1535
|
+
from a stability score (Eq. 2-4) computed ONCE before decoding starts,
|
|
1536
|
+
from (a) the audio encoder's own per-layer hidden-state trajectory on
|
|
1537
|
+
the *unblurred* audio, and (b) the decoder's per-layer attention to
|
|
1538
|
+
audio tokens during the prefill -- see
|
|
1539
|
+
:func:`evalrx.models.paper_methods.tcd.aggregate_stability` for
|
|
1540
|
+
why those two must have equal layer counts to be faithful, and
|
|
1541
|
+
``paper_method_fidelity("tcd")`` for which registered specs qualify.
|
|
1542
|
+
"""
|
|
1543
|
+
from evalrx.core.case import Inputs
|
|
1544
|
+
from evalrx.models.paper_methods import tcd
|
|
1545
|
+
|
|
1546
|
+
hp = hyperparams or tcd.TCDHyperparams()
|
|
1547
|
+
logger.debug(
|
|
1548
|
+
"%s: generate_tcd(l_attn=%s, tau=%s, gamma_gate=%s)",
|
|
1549
|
+
self.spec.key, hp.l_attn, hp.tau, hp.gamma_gate,
|
|
1550
|
+
)
|
|
1551
|
+
if self.spec.audio is None:
|
|
1552
|
+
raise ValueError(f"{self.spec.key}: TCD requires an audio-capable spec")
|
|
1553
|
+
audio = getattr(inputs, "audio", None) if isinstance(inputs, Inputs) else None
|
|
1554
|
+
if audio is None or isinstance(audio, (list, tuple)):
|
|
1555
|
+
raise ValueError("TCD requires exactly one audio clip")
|
|
1556
|
+
|
|
1557
|
+
import torch
|
|
1558
|
+
|
|
1559
|
+
model, processor = self._loaded
|
|
1560
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1561
|
+
waveform = _resolve_audio(audio)
|
|
1562
|
+
original_inputs = Inputs(
|
|
1563
|
+
prompt=self._as_prompt(inputs),
|
|
1564
|
+
image=getattr(inputs, "image", None),
|
|
1565
|
+
audio=waveform,
|
|
1566
|
+
video=getattr(inputs, "video", None),
|
|
1567
|
+
)
|
|
1568
|
+
enc, ids, _tokens, _ttm = self._encode_vlm(original_inputs, model, processor)
|
|
1569
|
+
enc.pop("token_type_ids", None)
|
|
1570
|
+
audio_token_id = _read_nested_attr(
|
|
1571
|
+
model.config, self.spec.audio.audio_token_id_attr, default=None
|
|
1572
|
+
)
|
|
1573
|
+
if audio_token_id is None:
|
|
1574
|
+
raise ValueError(f"{self.spec.key}: could not resolve the audio-token id from config")
|
|
1575
|
+
audio_mask = torch.tensor(ids, device=enc["input_ids"].device) == int(audio_token_id)
|
|
1576
|
+
if not bool(audio_mask.any()):
|
|
1577
|
+
raise ValueError(f"{self.spec.key}: no audio tokens found in the encoded prompt")
|
|
1578
|
+
|
|
1579
|
+
audio_tower = _read_nested_attr(model, self.spec.audio.audio_tower, default=None)
|
|
1580
|
+
if audio_tower is None:
|
|
1581
|
+
raise ValueError(
|
|
1582
|
+
f"{self.spec.key}: could not resolve audio_tower={self.spec.audio.audio_tower!r}"
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
# -- Eq. 2-3: encoder-side per-layer stability, on the UNBLURRED audio --
|
|
1586
|
+
with torch.no_grad():
|
|
1587
|
+
audio_out = audio_tower(
|
|
1588
|
+
enc["input_features"], output_hidden_states=True, return_dict=True
|
|
1589
|
+
)
|
|
1590
|
+
# hidden_states[0] is pre-layer-0 embeddings, not a layer's own output.
|
|
1591
|
+
# Verified against Qwen2AudioEncoder.forward: entries 1..N-1 are the
|
|
1592
|
+
# RAW pre-pool output of layers 0..N-2 (seq_len == max_source_positions,
|
|
1593
|
+
# e.g. 1500), but the LAST entry (N) is layer N-1's output after
|
|
1594
|
+
# avg_pooler + layer_norm have already run -- half the seq_len and a
|
|
1595
|
+
# LayerNorm-pinned scale, not a like-for-like continuation of the rest.
|
|
1596
|
+
# layer_stability's M_l/F_l are computed independently per entry (no
|
|
1597
|
+
# cross-layer diffs), so this doesn't break Eq. 2-3, but it does mean
|
|
1598
|
+
# the LAST layer's S_l sits on a different footing before Eq. 4
|
|
1599
|
+
# softmax-weights it back in -- an approximation, not a bug, and one
|
|
1600
|
+
# this codebase's convention is to say plainly rather than round off.
|
|
1601
|
+
encoder_states = [h[0].float() for h in audio_out.hidden_states[1:]]
|
|
1602
|
+
layer_stability_scores = tcd.layer_stability(encoder_states, eps=hp.eps)
|
|
1603
|
+
|
|
1604
|
+
# -- prefill: original branch (also seeds its KV cache) --
|
|
1605
|
+
with torch.no_grad():
|
|
1606
|
+
prefill = model(**enc, use_cache=True, output_attentions=True, return_dict=True)
|
|
1607
|
+
if not getattr(prefill, "attentions", None):
|
|
1608
|
+
raise ValueError(f"{self.spec.key}: TCD requires eager self-attention outputs")
|
|
1609
|
+
|
|
1610
|
+
# -- Eq. 4: aggregate stability, weighted by the decoder's per-layer
|
|
1611
|
+
# audio-attention ratio from that same prefill. See
|
|
1612
|
+
# paper_method_fidelity("tcd") for the equal-layer-count requirement
|
|
1613
|
+
# this truncation is standing in for on a mismatched-depth spec.
|
|
1614
|
+
# Indexed straight off prefill.attentions (still bf16) one layer at a
|
|
1615
|
+
# time -- audio_attention_ratio does its own float() per call, so this
|
|
1616
|
+
# never holds more than one layer's fp32 copy at once. A real MMAU clip
|
|
1617
|
+
# is ~780 tokens; materializing all 32 layers' (heads, 780, 780) fp32
|
|
1618
|
+
# attentions up front, as an earlier version of this method did, would
|
|
1619
|
+
# be several GB of copies purely for a scalar-per-layer reduction. --
|
|
1620
|
+
n_layers = min(layer_stability_scores.shape[0], len(prefill.attentions))
|
|
1621
|
+
layer_ratio = torch.stack(
|
|
1622
|
+
[
|
|
1623
|
+
tcd.audio_attention_ratio(prefill.attentions[i][0], audio_mask)
|
|
1624
|
+
for i in range(n_layers)
|
|
1625
|
+
]
|
|
1626
|
+
)
|
|
1627
|
+
stability = tcd.aggregate_stability(
|
|
1628
|
+
layer_stability_scores[:n_layers], layer_ratio, temperature=hp.tau
|
|
1629
|
+
)
|
|
1630
|
+
window_ms, lam = tcd.adaptive_blur_params(stability, hp)
|
|
1631
|
+
logger.debug(
|
|
1632
|
+
"%s: generate_tcd stability=%.4f window_ms=%.2f lam=%.4f",
|
|
1633
|
+
self.spec.key, stability, window_ms, lam,
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
# -- Eq. 1: blur + re-encode (once, up front -- not per decode step) --
|
|
1637
|
+
blurred_waveform = tcd.hann_blur_waveform(waveform, AUDIO_SAMPLE_RATE, window_ms)
|
|
1638
|
+
blurred_inputs = Inputs(
|
|
1639
|
+
prompt=original_inputs.prompt, image=original_inputs.image,
|
|
1640
|
+
audio=blurred_waveform, video=original_inputs.video,
|
|
1641
|
+
)
|
|
1642
|
+
blurred_enc, _, _, _ = self._encode_vlm(blurred_inputs, model, processor)
|
|
1643
|
+
blurred_enc.pop("token_type_ids", None)
|
|
1644
|
+
with torch.no_grad():
|
|
1645
|
+
blurred_prefill = model(**blurred_enc, use_cache=True, return_dict=True)
|
|
1646
|
+
|
|
1647
|
+
eos_ids = set()
|
|
1648
|
+
if getattr(tok, "eos_token_id", None) is not None:
|
|
1649
|
+
eos_ids.add(int(tok.eos_token_id))
|
|
1650
|
+
gen_eos = getattr(getattr(model, "generation_config", None), "eos_token_id", None)
|
|
1651
|
+
if isinstance(gen_eos, (list, tuple, set)):
|
|
1652
|
+
eos_ids.update(int(e) for e in gen_eos)
|
|
1653
|
+
elif gen_eos is not None:
|
|
1654
|
+
eos_ids.add(int(gen_eos))
|
|
1655
|
+
|
|
1656
|
+
max_new = int(max_new_tokens or self.runtime.max_new_tokens)
|
|
1657
|
+
original_kv = prefill.past_key_values
|
|
1658
|
+
blurred_kv = blurred_prefill.past_key_values
|
|
1659
|
+
z = prefill.logits[0, -1].float()
|
|
1660
|
+
z_tilde = blurred_prefill.logits[0, -1].float()
|
|
1661
|
+
last_layers_attn = [a[0].float() for a in prefill.attentions[-hp.l_attn :]]
|
|
1662
|
+
mask = audio_mask.clone()
|
|
1663
|
+
generated: list[int] = []
|
|
1664
|
+
|
|
1665
|
+
with torch.no_grad():
|
|
1666
|
+
for _ in range(max_new):
|
|
1667
|
+
r_t = float(
|
|
1668
|
+
torch.stack(
|
|
1669
|
+
[tcd.audio_attention_ratio(a, mask) for a in last_layers_attn]
|
|
1670
|
+
).mean()
|
|
1671
|
+
)
|
|
1672
|
+
entropy_hat = tcd.topk_renormalized_entropy(z, hp.k_ent)
|
|
1673
|
+
gate_value = tcd.reliance_gate(r_t, entropy_hat, hp)
|
|
1674
|
+
fused = tcd.fuse_logits(z, z_tilde, lam=lam, gate_value=gate_value, hp=hp)
|
|
1675
|
+
next_id = int(torch.argmax(fused))
|
|
1676
|
+
if next_id in eos_ids:
|
|
1677
|
+
break
|
|
1678
|
+
generated.append(next_id)
|
|
1679
|
+
|
|
1680
|
+
next_input = torch.tensor([[next_id]], device=z.device)
|
|
1681
|
+
out = model(
|
|
1682
|
+
input_ids=next_input, past_key_values=original_kv,
|
|
1683
|
+
use_cache=True, output_attentions=True, return_dict=True,
|
|
1684
|
+
)
|
|
1685
|
+
out_tilde = model(
|
|
1686
|
+
input_ids=next_input, past_key_values=blurred_kv,
|
|
1687
|
+
use_cache=True, return_dict=True,
|
|
1688
|
+
)
|
|
1689
|
+
original_kv = out.past_key_values
|
|
1690
|
+
blurred_kv = out_tilde.past_key_values
|
|
1691
|
+
z = out.logits[0, -1].float()
|
|
1692
|
+
z_tilde = out_tilde.logits[0, -1].float()
|
|
1693
|
+
last_layers_attn = [a[0].float() for a in out.attentions[-hp.l_attn :]]
|
|
1694
|
+
mask = torch.cat([mask, torch.zeros(1, dtype=torch.bool, device=mask.device)])
|
|
1695
|
+
|
|
1696
|
+
return tok.decode(generated, skip_special_tokens=True)
|
|
1697
|
+
|
|
1698
|
+
def generate_tcd_baseline(self, inputs: Any, *, max_new_tokens: int | None = None) -> str:
|
|
1699
|
+
"""Greedy baseline paired with :meth:`generate_tcd`.
|
|
1700
|
+
|
|
1701
|
+
``generate_tcd`` is greedy by construction (Eq. 9's fused logits feed
|
|
1702
|
+
a plain argmax, no sampler). ``Qwen2-Audio-7B-Instruct``'s own
|
|
1703
|
+
``generation_config`` defaults to ``do_sample=True`` (temperature 0.7,
|
|
1704
|
+
top_p 0.5, top_k 20) -- calling the bare :meth:`generate` for a
|
|
1705
|
+
baseline would inherit that and silently pair a sampled arm against a
|
|
1706
|
+
greedy one, exactly the mismatch ``generate_vcd_baseline`` exists to
|
|
1707
|
+
avoid for VCD (see its docstring). The paper's own baseline is
|
|
1708
|
+
greedy too (Table 7's "Baseline (Greedy)"; Section 4.1).
|
|
1709
|
+
"""
|
|
1710
|
+
logger.debug("%s: generate_tcd_baseline()", self.spec.key)
|
|
1711
|
+
if self.spec.audio is None:
|
|
1712
|
+
raise ValueError(f"{self.spec.key}: TCD requires an audio-capable spec")
|
|
1713
|
+
return self.generate(inputs, max_new_tokens=max_new_tokens, do_sample=False)
|
|
1714
|
+
|
|
1715
|
+
def generate_vicrop_consensus(
|
|
1716
|
+
self,
|
|
1717
|
+
inputs: Any,
|
|
1718
|
+
*,
|
|
1719
|
+
baseline_answer: str,
|
|
1720
|
+
layer: int | float = 14,
|
|
1721
|
+
) -> str:
|
|
1722
|
+
"""Use ViCrop only when independent crop and fused-view answers agree.
|
|
1723
|
+
|
|
1724
|
+
This is a deployment safety guard, not part of the source ViCrop
|
|
1725
|
+
method. It prevents a single misleading attention crop from replacing
|
|
1726
|
+
an otherwise stable baseline answer.
|
|
1727
|
+
"""
|
|
1728
|
+
import re
|
|
1729
|
+
|
|
1730
|
+
logger.debug("%s: generate_vicrop_consensus(layer=%s)", self.spec.key, layer)
|
|
1731
|
+
if self.spec.model_type != "llava":
|
|
1732
|
+
raise ValueError(f"{self.spec.key}: ViCrop is only native on the LLaVA executor")
|
|
1733
|
+
from evalrx.models.paper_methods.vicrop import prepare_views
|
|
1734
|
+
|
|
1735
|
+
image, crop, fused_prompt = prepare_views(self, inputs, layer=layer)
|
|
1736
|
+
fused = self.generate(Inputs(fused_prompt, [image, crop]))
|
|
1737
|
+
crop_only_prompt = (
|
|
1738
|
+
"The image is a task-relative crop selected from a larger scene. "
|
|
1739
|
+
"Answer only from visible evidence in this crop.\n\n"
|
|
1740
|
+
+ self._as_prompt(inputs)
|
|
1741
|
+
)
|
|
1742
|
+
crop_only = self.generate(Inputs(crop_only_prompt, crop))
|
|
1743
|
+
|
|
1744
|
+
def decision(text: str) -> str:
|
|
1745
|
+
lowered = str(text).lower()
|
|
1746
|
+
yes_no = re.search(r"\b(yes|no)\b", lowered)
|
|
1747
|
+
if yes_no:
|
|
1748
|
+
return yes_no.group(1)
|
|
1749
|
+
choices = re.findall(r"\b([a-d])\b", lowered)
|
|
1750
|
+
if choices:
|
|
1751
|
+
return choices[-1]
|
|
1752
|
+
return re.sub(r"\W+", "", lowered)
|
|
1753
|
+
|
|
1754
|
+
return fused if decision(fused) and decision(fused) == decision(crop_only) else baseline_answer
|
|
1755
|
+
|
|
1756
|
+
def generate_pai(
|
|
1757
|
+
self,
|
|
1758
|
+
inputs: Any,
|
|
1759
|
+
*,
|
|
1760
|
+
alpha: float = 0.2,
|
|
1761
|
+
guidance_scale: float = 2.0,
|
|
1762
|
+
start_layer: int = 2,
|
|
1763
|
+
end_layer: int = 32,
|
|
1764
|
+
max_new_tokens: int | None = None,
|
|
1765
|
+
) -> str:
|
|
1766
|
+
"""Run PAI's attention and classifier-free-guidance route on LLaVA.
|
|
1767
|
+
|
|
1768
|
+
The attention boost and the image-free classifier-free-guidance cache
|
|
1769
|
+
follow the released PAI decoding path. It remains an architecture
|
|
1770
|
+
specialization because the source uses its pinned LLaVA stack.
|
|
1771
|
+
"""
|
|
1772
|
+
import torch
|
|
1773
|
+
|
|
1774
|
+
logger.debug(
|
|
1775
|
+
"%s: generate_pai(alpha=%s, guidance_scale=%s, start_layer=%s, end_layer=%s)",
|
|
1776
|
+
self.spec.key, alpha, guidance_scale, start_layer, end_layer,
|
|
1777
|
+
)
|
|
1778
|
+
if self.spec.model_type != "llava":
|
|
1779
|
+
raise ValueError(f"{self.spec.key}: PAI is only native on the LLaVA executor")
|
|
1780
|
+
model, processor = self._loaded
|
|
1781
|
+
enc, _, _, _ = self._encode_vlm(inputs, model, processor)
|
|
1782
|
+
enc.pop("token_type_ids", None)
|
|
1783
|
+
image_id = getattr(model.config, "image_token_index", None)
|
|
1784
|
+
if image_id is None:
|
|
1785
|
+
raise ValueError(f"{self.spec.key}: PAI requires config.image_token_index")
|
|
1786
|
+
positions = (enc["input_ids"][0] == int(image_id)).nonzero().flatten()
|
|
1787
|
+
if positions.numel() == 0 or not bool((positions[1:] == positions[:-1] + 1).all()):
|
|
1788
|
+
raise ValueError(f"{self.spec.key}: PAI requires one contiguous image-token span")
|
|
1789
|
+
from transformers.generation.logits_process import LogitsProcessorList
|
|
1790
|
+
|
|
1791
|
+
from evalrx.models.paper_methods.pai import (
|
|
1792
|
+
PAICFGLogitsProcessor,
|
|
1793
|
+
image_attention_boost,
|
|
1794
|
+
)
|
|
1795
|
+
|
|
1796
|
+
# PAI's reference code patches the LLaMA language model, whereas HF
|
|
1797
|
+
# LLaVA wraps it in ``LlavaForConditionalGeneration``.
|
|
1798
|
+
language_model = getattr(model, "language_model", model)
|
|
1799
|
+
unconditional_ids = torch.cat(
|
|
1800
|
+
(enc["input_ids"][:, : positions[0]], enc["input_ids"][:, positions[-1] + 1 :]),
|
|
1801
|
+
dim=1,
|
|
1802
|
+
)
|
|
1803
|
+
cfg = PAICFGLogitsProcessor(
|
|
1804
|
+
model,
|
|
1805
|
+
unconditional_ids,
|
|
1806
|
+
guidance_scale=guidance_scale,
|
|
1807
|
+
start_layer=start_layer,
|
|
1808
|
+
end_layer=end_layer,
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
with image_attention_boost(
|
|
1812
|
+
language_model,
|
|
1813
|
+
image_start=int(positions[0]),
|
|
1814
|
+
image_end=int(positions[-1]) + 1,
|
|
1815
|
+
alpha=alpha,
|
|
1816
|
+
start_layer=start_layer,
|
|
1817
|
+
end_layer=end_layer,
|
|
1818
|
+
):
|
|
1819
|
+
with torch.no_grad():
|
|
1820
|
+
out = model.generate(
|
|
1821
|
+
**enc,
|
|
1822
|
+
max_new_tokens=max_new_tokens or self.runtime.max_new_tokens,
|
|
1823
|
+
do_sample=False,
|
|
1824
|
+
logits_processor=LogitsProcessorList([cfg]),
|
|
1825
|
+
)
|
|
1826
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1827
|
+
return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
|
|
1828
|
+
|
|
1829
|
+
def chat(self, messages: list, tools=None) -> ChatTurn:
|
|
1830
|
+
"""Tool-aware turn via the model's chat template.
|
|
1831
|
+
|
|
1832
|
+
transformers' ``apply_chat_template(tools=...)`` accepts OpenAI-format tool
|
|
1833
|
+
schemas and renders them into the prompt; the model emits the call as text
|
|
1834
|
+
which the (Qwen/Hermes) codec parses out — so ``raw_tool_calls`` is None.
|
|
1835
|
+
|
|
1836
|
+
Multimodal: messages may carry transformers-style content blocks
|
|
1837
|
+
(``{"type": "image", "image": ...}``); on a VLM the images are routed
|
|
1838
|
+
through the processor so each block's placeholder tokens line up with
|
|
1839
|
+
its pixels — this is what lets the agent loop feed tool-returned crops
|
|
1840
|
+
back to the model.
|
|
1841
|
+
"""
|
|
1842
|
+
import torch
|
|
1843
|
+
|
|
1844
|
+
if Capability.TOOL_CALLS not in self.capabilities:
|
|
1845
|
+
raise CapabilityError(
|
|
1846
|
+
analyzer="chat", model=repr(self), missing={Capability.TOOL_CALLS}
|
|
1847
|
+
)
|
|
1848
|
+
model, processor = self._loaded
|
|
1849
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1850
|
+
images = _collect_message_images(messages) if self.spec.is_vlm else []
|
|
1851
|
+
if images:
|
|
1852
|
+
try:
|
|
1853
|
+
text = processor.apply_chat_template(
|
|
1854
|
+
messages,
|
|
1855
|
+
tools=tools,
|
|
1856
|
+
add_generation_prompt=True,
|
|
1857
|
+
tokenize=False,
|
|
1858
|
+
**self.spec.chat_template_kwargs,
|
|
1859
|
+
)
|
|
1860
|
+
except (
|
|
1861
|
+
TypeError
|
|
1862
|
+
): # older processors don't take tools= — same jinja template lives on the tokenizer
|
|
1863
|
+
text = tok.apply_chat_template(
|
|
1864
|
+
messages,
|
|
1865
|
+
tools=tools,
|
|
1866
|
+
add_generation_prompt=True,
|
|
1867
|
+
tokenize=False,
|
|
1868
|
+
**self.spec.chat_template_kwargs,
|
|
1869
|
+
)
|
|
1870
|
+
enc = processor(text=[text], images=images, return_tensors="pt")
|
|
1871
|
+
enc.pop("token_type_ids", None) # some VLM processors emit this; generate() rejects it
|
|
1872
|
+
enc = enc.to(next(model.parameters()).device)
|
|
1873
|
+
else:
|
|
1874
|
+
text = tok.apply_chat_template(
|
|
1875
|
+
messages,
|
|
1876
|
+
tools=tools,
|
|
1877
|
+
add_generation_prompt=True,
|
|
1878
|
+
tokenize=False,
|
|
1879
|
+
**self.spec.chat_template_kwargs, # e.g. {"enable_thinking": False} for Qwen3
|
|
1880
|
+
)
|
|
1881
|
+
enc = tok(text, return_tensors="pt").to(next(model.parameters()).device)
|
|
1882
|
+
with torch.no_grad():
|
|
1883
|
+
out = model.generate(**enc, max_new_tokens=self.runtime.max_new_tokens)
|
|
1884
|
+
n_in = enc["input_ids"].shape[1]
|
|
1885
|
+
new_ids = _new_tokens(out[0], enc["input_ids"][0])
|
|
1886
|
+
gen = tok.decode(new_ids, skip_special_tokens=True)
|
|
1887
|
+
usage = {"prompt_tokens": int(n_in), "completion_tokens": int(new_ids.shape[0])}
|
|
1888
|
+
return ChatTurn(text=gen, raw_tool_calls=None, usage=usage)
|
|
1889
|
+
|
|
1890
|
+
def _encode_vlm(self, inputs, model, processor):
|
|
1891
|
+
"""Encode an (image/video/audio, text) input and build its TokenTypeMap.
|
|
1892
|
+
|
|
1893
|
+
Despite the name (kept to avoid touching every call site), this is the
|
|
1894
|
+
general multimodal encode path: it also carries ``inputs.audio`` for
|
|
1895
|
+
omni/audio-only specs. Builds the TokenTypeMap from the processor output
|
|
1896
|
+
BEFORE moving to device, using the live config (image_token_id,
|
|
1897
|
+
vision_config.spatial_merge_size) + the spec's VisionSpec — so token ids /
|
|
1898
|
+
merge sizes are never hard-coded. ``self.spec.vision is None`` (an
|
|
1899
|
+
audio-only spec) skips the TokenTypeMap entirely — it is VLM-specific.
|
|
1900
|
+
|
|
1901
|
+
``inputs.video`` (list of PIL frames) takes priority over ``inputs.image``:
|
|
1902
|
+
each frame gets its own ``{"type": "image"}`` content slot so the processor
|
|
1903
|
+
inserts a separate image-token block per frame, enabling multi-frame /
|
|
1904
|
+
temporal inputs without a native video tower.
|
|
1905
|
+
|
|
1906
|
+
Audio is independent of the image/video branch: any spec with
|
|
1907
|
+
``self.spec.audio is not None`` that receives ``inputs.audio`` gets an
|
|
1908
|
+
``{"type": "audio"}`` content block plus the decoded waveform(s) passed
|
|
1909
|
+
through the processor's ``audio=`` kwarg at :data:`AUDIO_SAMPLE_RATE`.
|
|
1910
|
+
"""
|
|
1911
|
+
from evalrx.core.tokentype import build_token_type_map
|
|
1912
|
+
|
|
1913
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1914
|
+
prompt = self._as_prompt(inputs)
|
|
1915
|
+
image = getattr(inputs, "image", None) if isinstance(inputs, Inputs) else None
|
|
1916
|
+
video = getattr(inputs, "video", None) if isinstance(inputs, Inputs) else None
|
|
1917
|
+
audio = getattr(inputs, "audio", None) if isinstance(inputs, Inputs) else None
|
|
1918
|
+
|
|
1919
|
+
if video is not None:
|
|
1920
|
+
# Multi-frame path: one <image> placeholder per frame, frames passed in order.
|
|
1921
|
+
content = [{"type": "image"} for _ in video] + [{"type": "text", "text": prompt}]
|
|
1922
|
+
images = list(video)
|
|
1923
|
+
elif image is not None:
|
|
1924
|
+
images = list(image) if isinstance(image, (list, tuple)) else [image]
|
|
1925
|
+
content = [{"type": "image"} for _ in images] + [{"type": "text", "text": prompt}]
|
|
1926
|
+
else:
|
|
1927
|
+
content = [{"type": "text", "text": prompt}]
|
|
1928
|
+
images = []
|
|
1929
|
+
|
|
1930
|
+
audios: list = []
|
|
1931
|
+
if self.spec.audio is not None and audio is not None:
|
|
1932
|
+
raw_audios = list(audio) if isinstance(audio, (list, tuple)) else [audio]
|
|
1933
|
+
audios = [_resolve_audio(a) for a in raw_audios]
|
|
1934
|
+
# Audio blocks precede the text block, matching the image/video
|
|
1935
|
+
# convention above; order must match the ``audio=`` list below.
|
|
1936
|
+
content = [{"type": "audio"} for _ in audios] + content
|
|
1937
|
+
|
|
1938
|
+
if self.spec.model_type == "instructblip":
|
|
1939
|
+
if len(images) != 1:
|
|
1940
|
+
raise ValueError("InstructBLIP supports exactly one image per request")
|
|
1941
|
+
# InstructBLIP has no chat template or decoder image placeholder:
|
|
1942
|
+
# its processor returns vision pixels plus an independent Q-Former
|
|
1943
|
+
# text sequence. The latter is what ICD perturbs. Vicuna-based
|
|
1944
|
+
# InstructBLIP expects an explicit answer turn; without it the
|
|
1945
|
+
# checkpoint often terminates immediately after echoing the prompt.
|
|
1946
|
+
qformer_prompt = prompt
|
|
1947
|
+
decoder_prompt = f"Question: {prompt} Answer:"
|
|
1948
|
+
enc = processor(images=images[0], text=decoder_prompt, return_tensors="pt")
|
|
1949
|
+
# The published ICD implementation gives the Q-Former the raw
|
|
1950
|
+
# question while the Vicuna decoder receives its answer template.
|
|
1951
|
+
# Override the processor's coupled default to retain that split.
|
|
1952
|
+
qformer = processor.qformer_tokenizer(
|
|
1953
|
+
qformer_prompt, return_tensors="pt", padding="longest", truncation=True
|
|
1954
|
+
)
|
|
1955
|
+
enc["qformer_input_ids"] = qformer["input_ids"]
|
|
1956
|
+
enc["qformer_attention_mask"] = qformer["attention_mask"]
|
|
1957
|
+
else:
|
|
1958
|
+
text = processor.apply_chat_template(
|
|
1959
|
+
[{"role": "user", "content": content}],
|
|
1960
|
+
add_generation_prompt=True,
|
|
1961
|
+
tokenize=False,
|
|
1962
|
+
**self.spec.chat_template_kwargs,
|
|
1963
|
+
)
|
|
1964
|
+
proc_kwargs = {"text": [text], "return_tensors": "pt"}
|
|
1965
|
+
if images:
|
|
1966
|
+
proc_kwargs["images"] = images
|
|
1967
|
+
if audios:
|
|
1968
|
+
_check_audio_duration(audios, processor, self.spec.key)
|
|
1969
|
+
proc_kwargs["audio"] = audios
|
|
1970
|
+
proc_kwargs["sampling_rate"] = AUDIO_SAMPLE_RATE
|
|
1971
|
+
enc = processor(**proc_kwargs)
|
|
1972
|
+
ttm = (
|
|
1973
|
+
build_token_type_map(enc["input_ids"], enc, model.config, self.spec.vision)
|
|
1974
|
+
if self.spec.vision is not None
|
|
1975
|
+
else None
|
|
1976
|
+
)
|
|
1977
|
+
enc = enc.to(next(model.parameters()).device)
|
|
1978
|
+
ids = enc["input_ids"][0].tolist()
|
|
1979
|
+
tokens = [tok.decode([i]) for i in ids]
|
|
1980
|
+
return enc, ids, tokens, ttm
|
|
1981
|
+
|
|
1982
|
+
def forward(self, inputs: Any, capture: set[Capability], spec=None) -> Trace:
|
|
1983
|
+
import torch
|
|
1984
|
+
|
|
1985
|
+
model, processor = self._loaded
|
|
1986
|
+
if self.spec.needs_multimodal_encode:
|
|
1987
|
+
enc, token_ids, tokens, ttm = self._encode_vlm(inputs, model, processor)
|
|
1988
|
+
else:
|
|
1989
|
+
tok = getattr(processor, "tokenizer", processor)
|
|
1990
|
+
enc = self._encode(self._as_prompt(inputs))
|
|
1991
|
+
token_ids = enc["input_ids"][0].tolist()
|
|
1992
|
+
tokens = [tok.decode([t]) for t in token_ids]
|
|
1993
|
+
ttm = None
|
|
1994
|
+
|
|
1995
|
+
extras: dict = {"attn_semantics": self.spec.attn_semantics.value}
|
|
1996
|
+
if self.spec.vision is not None:
|
|
1997
|
+
image = getattr(inputs, "image", None) if isinstance(inputs, Inputs) else None
|
|
1998
|
+
video = getattr(inputs, "video", None) if isinstance(inputs, Inputs) else None
|
|
1999
|
+
if image is not None or video is not None:
|
|
2000
|
+
_populate_vision_extras(
|
|
2001
|
+
extras, torch.tensor(token_ids), enc, model.config, self.spec.vision
|
|
2002
|
+
)
|
|
2003
|
+
if self.spec.audio is not None:
|
|
2004
|
+
audio = getattr(inputs, "audio", None) if isinstance(inputs, Inputs) else None
|
|
2005
|
+
if audio is not None:
|
|
2006
|
+
_populate_audio_extras(extras, torch.tensor(token_ids), model.config, self.spec.audio)
|
|
2007
|
+
|
|
2008
|
+
flags = {flag: True for cap, flag in _CAPTURE_FLAGS.items() if cap in capture}
|
|
2009
|
+
enc.pop("token_type_ids", None) # some VLM processors emit this; forward() rejects it
|
|
2010
|
+
with torch.no_grad():
|
|
2011
|
+
outputs = model(**enc, **flags)
|
|
2012
|
+
|
|
2013
|
+
layers = spec.layers if spec is not None else None
|
|
2014
|
+
to_cpu = spec.to_cpu if spec is not None else True
|
|
2015
|
+
|
|
2016
|
+
def _maybe_subset(seq):
|
|
2017
|
+
return [seq[i] for i in layers] if layers is not None else list(seq)
|
|
2018
|
+
|
|
2019
|
+
def _move(t):
|
|
2020
|
+
return t.cpu() if to_cpu else t
|
|
2021
|
+
|
|
2022
|
+
provided: set[Capability] = set()
|
|
2023
|
+
attentions = hidden_states = logits = None
|
|
2024
|
+
if Capability.ATTENTION in capture:
|
|
2025
|
+
if getattr(outputs, "attentions", None) is None:
|
|
2026
|
+
raise RuntimeError(
|
|
2027
|
+
f"{self!r}: ATTENTION was requested but the model returned no attentions. "
|
|
2028
|
+
"Load it with attn_implementation='eager' (sdpa/flash silently return None)."
|
|
2029
|
+
)
|
|
2030
|
+
attentions = [_move(a.squeeze(0)) for a in _maybe_subset(outputs.attentions)]
|
|
2031
|
+
provided.add(Capability.ATTENTION)
|
|
2032
|
+
if (
|
|
2033
|
+
Capability.HIDDEN_STATES in capture
|
|
2034
|
+
and getattr(outputs, "hidden_states", None) is not None
|
|
2035
|
+
):
|
|
2036
|
+
hidden_states = [_move(h.squeeze(0)) for h in _maybe_subset(outputs.hidden_states)]
|
|
2037
|
+
provided.add(Capability.HIDDEN_STATES)
|
|
2038
|
+
if Capability.LOGITS in capture:
|
|
2039
|
+
logits = _move(outputs.logits.squeeze(0))
|
|
2040
|
+
provided.add(Capability.LOGITS)
|
|
2041
|
+
|
|
2042
|
+
return Trace(
|
|
2043
|
+
tokens=tokens,
|
|
2044
|
+
token_ids=token_ids,
|
|
2045
|
+
provided=provided,
|
|
2046
|
+
attentions=attentions,
|
|
2047
|
+
hidden_states=hidden_states,
|
|
2048
|
+
logits=logits,
|
|
2049
|
+
token_type_map=ttm,
|
|
2050
|
+
extras=extras,
|
|
2051
|
+
)
|
|
2052
|
+
|
|
2053
|
+
def __repr__(self) -> str:
|
|
2054
|
+
status = "loaded" if self._hf else "lazy"
|
|
2055
|
+
return f"HFLocalModel(key={self.spec.key!r}, {status})"
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
class HFLocalBackend(Backend):
|
|
2059
|
+
kind = "hf_local"
|
|
2060
|
+
# Superset the backend CAN provide; the actual per-model set is computed in
|
|
2061
|
+
# HFLocalModel.__init__ (e.g. TOOL_CALLS only when the spec's template supports it).
|
|
2062
|
+
capabilities = frozenset(
|
|
2063
|
+
{
|
|
2064
|
+
Capability.GENERATE,
|
|
2065
|
+
Capability.TOOL_CALLS,
|
|
2066
|
+
Capability.LOGITS,
|
|
2067
|
+
Capability.LOGPROBS,
|
|
2068
|
+
Capability.HIDDEN_STATES,
|
|
2069
|
+
Capability.ATTENTION,
|
|
2070
|
+
}
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
def build(self, spec, runtime: RuntimeConfig) -> HFLocalModel:
|
|
2074
|
+
return HFLocalModel(spec, runtime)
|