Qingpeng Kong commited on
Commit
3e72399
·
0 Parent(s):

clean initial state

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +41 -0
  2. .gitignore +3 -0
  3. README.md +41 -0
  4. app.py +5 -0
  5. attribution/__init__.py +0 -0
  6. attribution/attribution.py +1262 -0
  7. attribution/image_masker.py +182 -0
  8. attribution/masker.py +149 -0
  9. attribution/mm_game.py +922 -0
  10. attribution/proxyspex.py +311 -0
  11. attribution/set_mm.py +2397 -0
  12. attribution/utils.py +438 -0
  13. audioop.py +31 -0
  14. datasets/BBQ_disamb.csv +3 -0
  15. datasets/BarExam_qa.csv +3 -0
  16. datasets/CNN_dailymail.csv +3 -0
  17. datasets/bbh_causal_judgement.csv +3 -0
  18. datasets/bbh_snarks.csv +3 -0
  19. datasets/drop.csv +3 -0
  20. datasets/eSNLI.csv +3 -0
  21. datasets/fever.csv +3 -0
  22. datasets/hotpot_qa.csv +3 -0
  23. datasets/medical_qa.csv +3 -0
  24. loader/__init__.py +84 -0
  25. loader/data.py +179 -0
  26. loader/models.py +65 -0
  27. loader/models_mm_hf.py +252 -0
  28. loader/models_mm_vllm.py +165 -0
  29. loader/models_vllm.py +218 -0
  30. loader/perplexity.py +538 -0
  31. loader/results.py +468 -0
  32. loader/utils.py +42 -0
  33. requirements.txt +11 -0
  34. results/coco_mm/coco_133233_original.png +3 -0
  35. results/coco_mm/coco_133233_overlay.png +3 -0
  36. results/coco_mm/coco_133233_segmap.png +3 -0
  37. results/coco_mm/coco_133233_summary.txt +166 -0
  38. results/coco_mm/coco_281447_original.png +3 -0
  39. results/coco_mm/coco_281447_overlay.png +3 -0
  40. results/coco_mm/coco_281447_segmap.png +3 -0
  41. results/coco_mm/coco_281447_summary.txt +175 -0
  42. results/coco_mm/coco_307172_original.png +3 -0
  43. results/coco_mm/coco_307172_overlay.png +3 -0
  44. results/coco_mm/coco_307172_segmap.png +3 -0
  45. results/coco_mm/coco_307172_summary.txt +183 -0
  46. results/coco_mm/coco_448256_original.png +3 -0
  47. results/coco_mm/coco_448256_overlay.png +3 -0
  48. results/coco_mm/coco_448256_segmap.png +3 -0
  49. results/coco_mm/coco_448256_summary.txt +176 -0
  50. results/coco_mm/coco_56350_original.png +3 -0
.gitattributes ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
37
+ *.jpg filter=lfs diff=lfs merge=lfs -text
38
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
39
+ *.docx filter=lfs diff=lfs merge=lfs -text
40
+ *.csv filter=lfs diff=lfs merge=lfs -text
41
+ *.log filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ logs/
README.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AttrLLM Medical Attribution
3
+ emoji: 🏥
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: "5.0.0"
8
+ python_version: "3.10"
9
+ app_file: app.py
10
+ pinned: false
11
+ ---
12
+
13
+ # AttrLLM — Medical Image Attribution
14
+
15
+ Interactive visualization of **feature-level attributions** for medical images,
16
+ computed via game-theoretic methods (Shapley values, Banzhaf indices).
17
+
18
+ ## What's in this Space
19
+
20
+ Three precomputed attribution tabs — no GPU required:
21
+
22
+ | Tab | Dataset | Examples | Methods |
23
+ |---|---|---|---|
24
+ | **Medical Benchmark** | PubMedVision | 5 curated (CT, MRI, Microscopy) | BiomedCLIP + LLaVA-Med (UnSAM) |
25
+ | **MIMIC-CXR** | MIMIC-CXR-JPG | 10 chest X-rays (10 pathologies) | BiomedCLIP + LLaVA-Med (UnSAM) |
26
+ | **MS-COCO** | MS-COCO | 5 examples | CLIP cross-modal + interactive view |
27
+
28
+ Each example shows:
29
+ - **Segment overlay** — image regions colored by Shapley attribution strength
30
+ - **Token bar chart** — which caption words the model finds most diagnostic
31
+ - **Interactive cross-modal view** — click a region to see linked tokens, and vice versa
32
+ - **Side-by-side method comparison** — BiomedCLIP vs LLaVA-Med
33
+
34
+ ## Attribution Methods
35
+
36
+ - **BiomedCLIP cross-modal**: UnSAM image segments × PubMedBERT tokens, scored via BiomedCLIP cosine similarity, Shapley values via ProxySPEX
37
+ - **LLaVA-Med (UnSAM)**: UnSAM segments, scored by log P(caption | masked image) using LLaVA-Med-7B, Shapley values via ProxySPEX
38
+
39
+ ## Citation
40
+
41
+ Part of the AttrLLM capstone project (NCSA Delta HPC, UIUC).
app.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from visualization.app import launch_app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ launch_app()
attribution/__init__.py ADDED
File without changes
attribution/attribution.py ADDED
@@ -0,0 +1,1262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import os
3
+ import time
4
+ import logging
5
+ from dataclasses import asdict, dataclass
6
+ from functools import lru_cache
7
+ from typing import Any, Dict, List, Sequence, Tuple
8
+
9
+ from attribution.masker import MaskingInfo, get_masker, mask_text
10
+ from attribution.proxyspex import run_proxyspex
11
+ from attribution.utils import (
12
+ AttributionResult,
13
+ banzhaf_interactions,
14
+ convert_to_heatmap_data,
15
+ influence_interactions,
16
+ mobius_to_banzhaf,
17
+ mobius_to_fourier,
18
+ mobius_to_influence,
19
+ mobius_to_shapley,
20
+ shapley_interactions,
21
+ )
22
+ from loader.data import get_example_by_id
23
+ from loader.perplexity import score_continuation, score_continuation_batch
24
+ import numpy as np
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ try:
29
+ from loader.models import BaseModel
30
+ except ImportError: # pragma: no cover - fallback type
31
+ BaseModel = Any # type: ignore
32
+
33
+
34
+ SUPPORTED_METHODS = {"shapley", "banzhaf", "influence"}
35
+ SUPPORTED_SCALARIZERS = {
36
+ "semantic_similarity",
37
+ "logprob",
38
+ "jointprob",
39
+ "geomean_jointprob",
40
+ "half_simlog",
41
+ }
42
+ MAX_MOBIUS_TERMS = 1000
43
+ DEFAULT_SIM_MODEL = "all-MiniLM-L6-v2"
44
+ # Pre-downloaded local copy (see scripts/download_st_model.py)
45
+ _LOCAL_ST_MODEL_DIR = os.path.join(
46
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
47
+ "models", "all_MiniLM_L6_v2",
48
+ )
49
+ MIN_LOGPROB_FALLBACK = -1e6
50
+ EXP_CLAMP_MIN = -200.0
51
+ EXP_CLAMP_MAX = 50.0
52
+
53
+ _ST_MODEL = None
54
+
55
+
56
+ def _get_st_model():
57
+ global _ST_MODEL
58
+ if _ST_MODEL is None:
59
+ try:
60
+ from sentence_transformers import SentenceTransformer
61
+ except ImportError as exc: # pragma: no cover
62
+ raise RuntimeError(
63
+ "sentence-transformers not installed; required for semantic_similarity scalarizer."
64
+ ) from exc
65
+ # Prefer the local snapshot if it exists.
66
+ if os.path.isdir(_LOCAL_ST_MODEL_DIR):
67
+ model_path = _LOCAL_ST_MODEL_DIR
68
+ logger.info("Loading SentenceTransformer from local dir: %s", model_path)
69
+ else:
70
+ model_path = DEFAULT_SIM_MODEL
71
+ logger.info("Local ST model not found; loading from HuggingFace hub: %s", model_path)
72
+ _ST_MODEL = SentenceTransformer(model_path)
73
+ return _ST_MODEL
74
+
75
+
76
+ @dataclass
77
+ class AttributorConfig:
78
+ """Configuration for the attribution pipeline."""
79
+
80
+ mask_level: str = "sentence"
81
+ method: str = "shapley"
82
+ interaction_order: int = 3
83
+ top_k_interactions: int = 10
84
+ random_seed: int = 0
85
+ scalarizer: str = "logprob" # "semantic_similarity" | "logprob" | "jointprob" | "geomean_jointprob" | "half_simlog"
86
+ embedding_model: str | None = None
87
+
88
+ def __post_init__(self) -> None:
89
+ normalized = self.method.lower()
90
+ if normalized not in SUPPORTED_METHODS:
91
+ raise ValueError(f"Unsupported attribution method: {self.method}")
92
+ self.method = normalized
93
+ self.scalarizer = (self.scalarizer or "logprob").lower()
94
+ if self.scalarizer not in SUPPORTED_SCALARIZERS:
95
+ raise ValueError(f"Unsupported scalarizer: {self.scalarizer}")
96
+
97
+
98
+ class Attributor:
99
+ """Encapsulates the full text attribution workflow."""
100
+
101
+ def __init__(self, model: BaseModel, config: AttributorConfig | None = None) -> None:
102
+ self.model = model
103
+ self.config = config or AttributorConfig()
104
+
105
+ def _mask_context(self, context: str) -> Tuple[List[str], MaskingInfo]:
106
+ """Split the context into maskable features and capture span metadata."""
107
+ masker_fn = get_masker(self.config.mask_level)
108
+ features, info = mask_text(context, masker_fn)
109
+ info.mask_level = self.config.mask_level
110
+
111
+ return features, info
112
+
113
+ def _build_set_function(
114
+ self,
115
+ features: Sequence[str],
116
+ answer: str,
117
+ reference_answer: str,
118
+ prompt: str,
119
+ ):
120
+ """
121
+ Build the cooperative game f(S) with a configurable scalarizer.
122
+ """
123
+ max_new_tokens_score = int(os.getenv("ATTRLLM_MAX_NEW_TOKENS_SCORE", "32"))
124
+ batch_size = int(os.getenv("VLLM_SCORE_BATCH_SIZE",
125
+ os.getenv("ATTRLLM_VLLM_BATCH_SIZE", "128")))
126
+ n_features = len(features)
127
+ scalarizer = self.config.scalarizer
128
+ prompt_cache: Dict[Tuple[str, str, int], Dict[str, Any]] = {}
129
+ error_cache: Dict[Tuple[int, ...], str | None] = {}
130
+ min_logprob_seen: Dict[str, float | None] = {"value": None}
131
+ answer = (answer or "").strip()
132
+ reference_answer = (reference_answer or "").strip()
133
+
134
+ def _safe_exp(log_value: float) -> float:
135
+ clamped = max(EXP_CLAMP_MIN, min(EXP_CLAMP_MAX, log_value))
136
+ return float(math.exp(clamped))
137
+
138
+ def _sanitize_score_value(
139
+ score_value: float,
140
+ *,
141
+ masked_context: str,
142
+ coalition_size: int,
143
+ raw_score: Any | None = None,
144
+ ) -> float:
145
+ if math.isfinite(score_value):
146
+ return float(score_value)
147
+ logger.warning(
148
+ "Non-finite scalarizer score; falling back to 0. "
149
+ "scalarizer=%s coalition_size=%d masked_len=%d raw_score=%s",
150
+ scalarizer,
151
+ coalition_size,
152
+ len(masked_context),
153
+ raw_score if raw_score is not None else score_value,
154
+ )
155
+ return 0.0
156
+
157
+ def _build_prompt(masked_context: str) -> str:
158
+ masked_context = masked_context or ""
159
+ p = prompt or ""
160
+ if p and masked_context:
161
+ return f"{p}\nContext: {masked_context}"
162
+ if p:
163
+ return p
164
+ return masked_context
165
+
166
+ full_context = " ".join(features).strip()
167
+ full_prompt = _build_prompt(full_context)
168
+ y_full = ""
169
+ y_len_tokens = 0
170
+ try:
171
+ if full_prompt:
172
+ outputs = self.model.infer(
173
+ [full_prompt],
174
+ max_new_tokens=max_new_tokens_score,
175
+ temperature=0.0,
176
+ top_p=1.0,
177
+ logprobs=0,
178
+ )
179
+ if outputs:
180
+ y_full = (outputs[0].text or "").strip()
181
+ token_ids = outputs[0].token_ids or []
182
+ y_len_tokens = len(token_ids) or len(y_full.split())
183
+ except Exception as exc:
184
+ logger.warning("Failed to generate unmasked output y: %s", exc)
185
+ y_full = ""
186
+ y_len_tokens = 0
187
+
188
+ if reference_answer:
189
+ scoring_target_source = "reference_answer"
190
+ scoring_target_text = reference_answer
191
+ elif answer:
192
+ scoring_target_source = "answer_input"
193
+ scoring_target_text = answer
194
+ else:
195
+ scoring_target_source = "model_output"
196
+ scoring_target_text = y_full
197
+
198
+ scoring_target_text = (scoring_target_text or "").strip()
199
+ if scoring_target_source == "model_output":
200
+ scoring_target_len_tokens = y_len_tokens
201
+ else:
202
+ scoring_target_len_tokens = len(scoring_target_text.split())
203
+
204
+ def _score_logprob(masked_context: str) -> Dict[str, Any]:
205
+ if not scoring_target_text:
206
+ return {
207
+ "logprob": MIN_LOGPROB_FALLBACK,
208
+ "total_logprob": MIN_LOGPROB_FALLBACK,
209
+ "avg_nll": 0.0,
210
+ "ppl": 1.0,
211
+ "target_len": scoring_target_len_tokens,
212
+ "generated": scoring_target_text,
213
+ "error": "empty scoring target",
214
+ }
215
+ prompt_text = _build_prompt(masked_context)
216
+ cache_key = (prompt_text, scoring_target_text, max_new_tokens_score)
217
+ if cache_key in prompt_cache:
218
+ score = prompt_cache[cache_key]
219
+ else:
220
+ score = score_continuation(
221
+ self.model,
222
+ prefix=prompt_text,
223
+ continuation=scoring_target_text,
224
+ max_new_tokens=max_new_tokens_score,
225
+ )
226
+ prompt_cache[cache_key] = score
227
+ avg_logprob = float(score.get("avg_logprob", float("-inf")))
228
+ target_len = int(score.get("target_len", 0) or 0)
229
+ if target_len <= 0:
230
+ target_len = scoring_target_len_tokens
231
+ seq_logprobs = score.get("sequence_logprobs", [])
232
+ if seq_logprobs:
233
+ total_logprob = float(seq_logprobs[0])
234
+ else:
235
+ total_logprob = avg_logprob * target_len if target_len else float("-inf")
236
+ error: str | None = None
237
+ if not math.isfinite(total_logprob):
238
+ total_logprob = MIN_LOGPROB_FALLBACK
239
+ error = "non-finite total_logprob"
240
+ if target_len > 0 and math.isfinite(total_logprob):
241
+ avg_nll = -total_logprob / target_len
242
+ ppl = _safe_exp(avg_nll)
243
+ else:
244
+ avg_nll = 0.0
245
+ ppl = 1.0
246
+ current_min = min_logprob_seen["value"]
247
+ if math.isfinite(total_logprob) and (current_min is None or total_logprob < current_min):
248
+ min_logprob_seen["value"] = total_logprob
249
+ return {
250
+ "logprob": total_logprob,
251
+ "total_logprob": total_logprob,
252
+ "avg_nll": avg_nll,
253
+ "ppl": ppl,
254
+ "target_len": target_len,
255
+ "generated": scoring_target_text,
256
+ "error": error,
257
+ }
258
+
259
+ def _logsumexp(a: float, b: float) -> float:
260
+ if not math.isfinite(a) and not math.isfinite(b):
261
+ return MIN_LOGPROB_FALLBACK
262
+ if not math.isfinite(a):
263
+ return b
264
+ if not math.isfinite(b):
265
+ return a
266
+ m = max(a, b)
267
+ return m + math.log(math.exp(a - m) + math.exp(b - m))
268
+
269
+ def _scalarize(masked_context: str) -> Dict[str, Any]:
270
+ if scalarizer == "semantic_similarity":
271
+ prompt_text = _build_prompt(masked_context)
272
+ try:
273
+ outputs = self.model.infer(
274
+ [prompt_text],
275
+ max_new_tokens=16,
276
+ temperature=0.0,
277
+ top_p=1.0,
278
+ logprobs=0,
279
+ )
280
+ y_s = (outputs[0].text or "").strip() if outputs else ""
281
+ except Exception as exc:
282
+ logger.warning("Generation failed for semantic_similarity: %s", exc)
283
+ y_s = ""
284
+
285
+ if not scoring_target_text or not y_s.strip():
286
+ logger.warning(
287
+ "semantic_similarity fallback to 0. scalarizer=%s masked_len=%d y_len=%d",
288
+ scalarizer,
289
+ len(masked_context),
290
+ len(y_s),
291
+ )
292
+ return {
293
+ "score": 0.0,
294
+ "total_logprob": 0.0,
295
+ "avg_nll": 0.0,
296
+ "ppl": 1.0,
297
+ "target_len": scoring_target_len_tokens,
298
+ "generated": y_s,
299
+ "error": "empty scoring target or y_s",
300
+ }
301
+
302
+ try:
303
+ model = _get_st_model()
304
+ emb = model.encode([scoring_target_text, y_s])
305
+ emb_arr = np.asarray(emb, dtype=np.float64)
306
+ norm_target = float(np.linalg.norm(emb_arr[0])) if emb_arr.shape[0] > 0 else 0.0
307
+ norm_masked = float(np.linalg.norm(emb_arr[1])) if emb_arr.shape[0] > 1 else 0.0
308
+ if (
309
+ emb_arr.shape[0] < 2
310
+ or not np.isfinite(norm_target)
311
+ or not np.isfinite(norm_masked)
312
+ or norm_target <= 0.0
313
+ or norm_masked <= 0.0
314
+ ):
315
+ sim = 0.0
316
+ error = "zero-norm or invalid embedding"
317
+ else:
318
+ sim = float(model.similarity(emb_arr, emb_arr)[0, 1])
319
+ error = None
320
+ if not np.isfinite(sim):
321
+ logger.warning(
322
+ "semantic_similarity non-finite score; fallback to 0. "
323
+ "masked_len=%d y_len=%d raw_score=%s",
324
+ len(masked_context),
325
+ len(y_s),
326
+ sim,
327
+ )
328
+ sim = 0.0
329
+ error = "non-finite semantic similarity"
330
+ except Exception as exc:
331
+ logger.warning("semantic_similarity failed: %s", exc)
332
+ sim = 0.0
333
+ error = "semantic_similarity exception"
334
+
335
+ return {
336
+ "score": sim,
337
+ "total_logprob": sim,
338
+ "avg_nll": 0.0,
339
+ "ppl": 1.0,
340
+ "target_len": scoring_target_len_tokens,
341
+ "generated": y_s,
342
+ "error": error,
343
+ }
344
+
345
+ logprob_details = _score_logprob(masked_context)
346
+ logprob = float(logprob_details.get("logprob", MIN_LOGPROB_FALLBACK))
347
+ if not math.isfinite(logprob):
348
+ logger.warning(
349
+ "logprob fallback to %s. scalarizer=%s masked_len=%d raw_score=%s",
350
+ MIN_LOGPROB_FALLBACK,
351
+ scalarizer,
352
+ len(masked_context),
353
+ logprob,
354
+ )
355
+ logprob = MIN_LOGPROB_FALLBACK
356
+ logprob_details["error"] = "non-finite logprob"
357
+
358
+ if scalarizer == "logprob":
359
+ score_value = logprob
360
+ elif scalarizer == "jointprob":
361
+ score_value = _safe_exp(logprob)
362
+ elif scalarizer == "geomean_jointprob":
363
+ denom = max(scoring_target_len_tokens, 1)
364
+ score_value = _safe_exp(logprob / denom)
365
+ elif scalarizer == "half_simlog":
366
+ c_val = min_logprob_seen["value"]
367
+ if c_val is None or not math.isfinite(c_val):
368
+ c_val = logprob
369
+ score_value = _logsumexp(logprob, c_val)
370
+ else:
371
+ raise ValueError(f"Unsupported scalarizer: {scalarizer}")
372
+
373
+ logprob_details.update({"score": float(score_value)})
374
+ return logprob_details
375
+
376
+ @lru_cache(maxsize=4096)
377
+ def cached(selection_tuple: Tuple[int, ...]) -> Tuple[float, float, float, float, int]:
378
+ masked_context = " ".join(
379
+ features[idx] for idx, active in enumerate(selection_tuple) if active
380
+ ).strip()
381
+ details = _scalarize(masked_context)
382
+ score_value = float(details.get("score", 0.0))
383
+ total_logprob = float(details.get("total_logprob", score_value))
384
+ avg_nll = float(details.get("avg_nll", 0.0))
385
+ ppl = float(details.get("ppl", 1.0))
386
+ target_len = int(details.get("target_len", 0) or 0)
387
+ generated_len = len(details.get("generated", "") or "")
388
+ coalition_size = sum(int(x) for x in selection_tuple)
389
+ error_cache[selection_tuple] = details.get("error")
390
+ if error_cache[selection_tuple]:
391
+ logger.warning(
392
+ "Scalarizer fallback: scalarizer=%s coalition_size=%d masked_len=%d y_len=%d error=%s raw_score=%s",
393
+ scalarizer,
394
+ coalition_size,
395
+ len(masked_context),
396
+ generated_len,
397
+ error_cache[selection_tuple],
398
+ details.get("score"),
399
+ )
400
+
401
+ score_value = _sanitize_score_value(
402
+ score_value,
403
+ masked_context=masked_context,
404
+ coalition_size=coalition_size,
405
+ raw_score=details.get("score"),
406
+ )
407
+ if not math.isfinite(total_logprob):
408
+ total_logprob = 0.0
409
+ avg_nll = 0.0
410
+ ppl = 1.0
411
+ return score_value, total_logprob, avg_nll, ppl, target_len
412
+
413
+ def set_function(selection) -> float:
414
+ if len(selection) != n_features:
415
+ raise ValueError("Selection length must match number of features.")
416
+ selection_tuple = tuple(int(x) for x in selection)
417
+ return cached(selection_tuple)[0]
418
+
419
+ def score_details(selection) -> Dict[str, Any]:
420
+ if len(selection) != n_features:
421
+ raise ValueError("Selection length must match number of features.")
422
+ selection_tuple = tuple(int(x) for x in selection)
423
+ score_value, total_logprob, avg_nll, ppl, target_len = cached(selection_tuple)
424
+ return {
425
+ "score": score_value,
426
+ "total_logprob": total_logprob,
427
+ "logprob": total_logprob,
428
+ "avg_nll": avg_nll,
429
+ "ppl": ppl,
430
+ "target_len": target_len,
431
+ "scalarizer": scalarizer,
432
+ "error": error_cache.get(selection_tuple),
433
+ }
434
+
435
+ set_function.score_details = score_details # type: ignore[attr-defined]
436
+
437
+ def _batch_semantic_similarity(
438
+ selection_tuples: List[Tuple[int, ...]],
439
+ masked_contexts: List[str],
440
+ ) -> List[float]:
441
+ """
442
+ Batch semantic similarity computation:
443
+ 1. Generate y_s for all masked contexts in batches (using LLM)
444
+ 2. Encode all texts (target + all y_s) in one batch with SentenceTransformer
445
+ 3. Compute cosine similarities
446
+ """
447
+ n_samples = len(selection_tuples)
448
+ if n_samples == 0:
449
+ return []
450
+
451
+ # Fallback if target is empty
452
+ if not scoring_target_text:
453
+ return [0.0] * n_samples
454
+
455
+ # Step 1: Generate y_s for all masked contexts in batches
456
+ prompts = [_build_prompt(mc) for mc in masked_contexts]
457
+ y_s_list: List[str] = []
458
+
459
+ for start in range(0, len(prompts), batch_size):
460
+ chunk = prompts[start : start + batch_size]
461
+ try:
462
+ outputs = self.model.infer(
463
+ chunk,
464
+ max_new_tokens=16,
465
+ temperature=0.0,
466
+ top_p=1.0,
467
+ logprobs=0,
468
+ )
469
+ for out in outputs:
470
+ y_s_list.append((out.text or "").strip() if out else "")
471
+ except Exception as exc:
472
+ logger.warning("Batch generation failed for semantic_similarity: %s", exc)
473
+ # Fill with empty strings for this batch
474
+ y_s_list.extend([""] * len(chunk))
475
+
476
+ # Step 2: Get the sentence transformer model
477
+ try:
478
+ st_model = _get_st_model()
479
+ except Exception as exc:
480
+ logger.warning("Failed to load SentenceTransformer: %s", exc)
481
+ return [0.0] * n_samples
482
+
483
+ # Step 3: Encode all texts in one batch
484
+ # texts_to_encode = [scoring_target_text] + y_s_list (index 0 is the target)
485
+ texts_to_encode = [scoring_target_text] + y_s_list
486
+ try:
487
+ embeddings = st_model.encode(
488
+ texts_to_encode,
489
+ show_progress_bar=False,
490
+ batch_size=batch_size,
491
+ )
492
+ emb_arr = np.asarray(embeddings, dtype=np.float64)
493
+ target_emb = emb_arr[0:1] # Shape: (1, dim)
494
+ y_s_embs = emb_arr[1:] # Shape: (n_samples, dim)
495
+ except Exception as exc:
496
+ logger.warning("Batch encoding failed for semantic_similarity: %s", exc)
497
+ return [0.0] * n_samples
498
+
499
+ target_norm = float(np.linalg.norm(target_emb[0])) if target_emb.shape[0] > 0 else 0.0
500
+ y_s_norms = np.linalg.norm(y_s_embs, axis=1) if y_s_embs.size else np.array([])
501
+ if not np.isfinite(target_norm) or target_norm <= 0.0:
502
+ logger.warning(
503
+ "semantic_similarity fallback to 0 in batch due to invalid target embedding norm. "
504
+ "scalarizer=%s",
505
+ scalarizer,
506
+ )
507
+ for sel_tuple in selection_tuples:
508
+ error_cache[sel_tuple] = "invalid target embedding"
509
+ return [0.0] * n_samples
510
+
511
+ # Step 4: Compute similarities
512
+ results: List[float] = []
513
+ try:
514
+ # Compute cosine similarity between target and each y_s
515
+ similarities = st_model.similarity(target_emb, y_s_embs) # Shape: (1, n_samples)
516
+ for i, sel_tuple in enumerate(selection_tuples):
517
+ y_s = y_s_list[i]
518
+ if not y_s.strip():
519
+ sim = 0.0
520
+ error_cache[sel_tuple] = "empty y_s"
521
+ elif (
522
+ i >= len(y_s_norms)
523
+ or not np.isfinite(float(y_s_norms[i]))
524
+ or float(y_s_norms[i]) <= 0.0
525
+ ):
526
+ sim = 0.0
527
+ error_cache[sel_tuple] = "invalid masked embedding"
528
+ else:
529
+ sim = float(similarities[0, i])
530
+ if not np.isfinite(sim):
531
+ sim = 0.0
532
+ error_cache[sel_tuple] = "non-finite semantic similarity"
533
+ else:
534
+ error_cache[sel_tuple] = None
535
+ coalition_size = sum(int(x) for x in sel_tuple)
536
+ sim = _sanitize_score_value(
537
+ sim,
538
+ masked_context=masked_contexts[i],
539
+ coalition_size=coalition_size,
540
+ raw_score=sim,
541
+ )
542
+ results.append(sim)
543
+ except Exception as exc:
544
+ logger.warning("Similarity computation failed: %s", exc)
545
+ return [0.0] * n_samples
546
+
547
+ return results
548
+
549
+ def batch_set_function(selections: Sequence[Sequence[int]]) -> List[float]:
550
+ selection_tuples = [tuple(int(x) for x in sel) for sel in selections]
551
+ masked_contexts = [
552
+ " ".join(features[idx] for idx, active in enumerate(sel) if active).strip()
553
+ for sel in selection_tuples
554
+ ]
555
+
556
+ # Handle semantic_similarity with batching
557
+ if scalarizer == "semantic_similarity":
558
+ return _batch_semantic_similarity(selection_tuples, masked_contexts)
559
+
560
+ logprob_scalarizers = {
561
+ "logprob",
562
+ "jointprob",
563
+ "geomean_jointprob",
564
+ "half_simlog",
565
+ }
566
+ if scalarizer not in logprob_scalarizers:
567
+ return [cached(sel)[0] for sel in selection_tuples]
568
+
569
+ if not scoring_target_text:
570
+ fallback_logprob = MIN_LOGPROB_FALLBACK
571
+ outputs: List[float] = []
572
+ for sel_tuple, mc in zip(selection_tuples, masked_contexts):
573
+ if scalarizer == "logprob":
574
+ score_value = fallback_logprob
575
+ elif scalarizer == "jointprob":
576
+ score_value = _safe_exp(fallback_logprob)
577
+ elif scalarizer == "geomean_jointprob":
578
+ denom = max(scoring_target_len_tokens, 1)
579
+ score_value = _safe_exp(fallback_logprob / denom)
580
+ elif scalarizer == "half_simlog":
581
+ score_value = _logsumexp(fallback_logprob, fallback_logprob)
582
+ else:
583
+ score_value = fallback_logprob
584
+ coalition_size = sum(int(x) for x in sel_tuple)
585
+ outputs.append(
586
+ _sanitize_score_value(
587
+ float(score_value),
588
+ masked_context=mc,
589
+ coalition_size=coalition_size,
590
+ raw_score=score_value,
591
+ )
592
+ )
593
+ return outputs
594
+
595
+ unique_map: Dict[Tuple[str, str, int], List[int]] = {}
596
+ for idx, mc in enumerate(masked_contexts):
597
+ prompt_text = _build_prompt(mc)
598
+ key = (prompt_text, scoring_target_text, max_new_tokens_score)
599
+ unique_map.setdefault(key, []).append(idx)
600
+
601
+ to_fetch_keys = []
602
+ to_fetch_contexts = []
603
+ for key in unique_map.keys():
604
+ if key not in prompt_cache:
605
+ to_fetch_keys.append(key)
606
+ to_fetch_contexts.append(key[0])
607
+
608
+ logger.info(
609
+ "[batch_set_function] total=%d unique_prompts=%d new_to_fetch=%d cached=%d batch_size=%d",
610
+ len(selection_tuples),
611
+ len(unique_map),
612
+ len(to_fetch_contexts),
613
+ len(prompt_cache),
614
+ batch_size,
615
+ )
616
+
617
+ if to_fetch_contexts:
618
+ batch_scores: List[Dict[str, Any]] = []
619
+ for start in range(0, len(to_fetch_contexts), batch_size):
620
+ chunk = to_fetch_contexts[start : start + batch_size]
621
+ logger.info(
622
+ "[batch_set_function] scoring chunk %d..%d of %d via vLLM",
623
+ start, start + len(chunk), len(to_fetch_contexts),
624
+ )
625
+ scored = score_continuation_batch(
626
+ self.model,
627
+ prefixes=chunk,
628
+ continuation=scoring_target_text,
629
+ max_new_tokens=max_new_tokens_score,
630
+ batch_size=batch_size,
631
+ )
632
+ batch_scores.extend(scored)
633
+ for key, score in zip(to_fetch_keys, batch_scores):
634
+ prompt_cache[key] = score
635
+
636
+ outputs: List[float] = []
637
+ for sel_tuple, mc in zip(selection_tuples, masked_contexts):
638
+ prompt_text = _build_prompt(mc)
639
+ key = (prompt_text, scoring_target_text, max_new_tokens_score)
640
+ score = prompt_cache.get(key)
641
+ if not score:
642
+ outputs.append(cached(sel_tuple)[0])
643
+ continue
644
+ avg_logprob = float(score.get("avg_logprob", float("-inf")))
645
+ target_len = int(score.get("target_len", 0) or 0)
646
+ if target_len <= 0:
647
+ target_len = scoring_target_len_tokens
648
+ seq_logprobs = score.get("sequence_logprobs", [])
649
+ if seq_logprobs:
650
+ total_logprob = float(seq_logprobs[0])
651
+ else:
652
+ total_logprob = avg_logprob * target_len if target_len else float("-inf")
653
+ if not math.isfinite(total_logprob):
654
+ total_logprob = MIN_LOGPROB_FALLBACK
655
+ current_min = min_logprob_seen["value"]
656
+ if math.isfinite(total_logprob) and (current_min is None or total_logprob < current_min):
657
+ min_logprob_seen["value"] = total_logprob
658
+
659
+ if scalarizer == "logprob":
660
+ score_value = total_logprob
661
+ elif scalarizer == "jointprob":
662
+ score_value = _safe_exp(total_logprob)
663
+ elif scalarizer == "geomean_jointprob":
664
+ denom = max(scoring_target_len_tokens, 1)
665
+ score_value = _safe_exp(total_logprob / denom)
666
+ elif scalarizer == "half_simlog":
667
+ c_val = min_logprob_seen["value"]
668
+ if c_val is None or not math.isfinite(c_val):
669
+ c_val = total_logprob
670
+ score_value = _logsumexp(total_logprob, c_val)
671
+ else:
672
+ score_value = total_logprob
673
+ coalition_size = sum(int(x) for x in sel_tuple)
674
+ outputs.append(
675
+ _sanitize_score_value(
676
+ float(score_value),
677
+ masked_context=mc,
678
+ coalition_size=coalition_size,
679
+ raw_score=score_value,
680
+ )
681
+ )
682
+ return outputs
683
+
684
+ set_function.batch = batch_set_function # type: ignore[attr-defined]
685
+
686
+ set_function.y_full = y_full # type: ignore[attr-defined]
687
+ set_function.y_len_tokens = y_len_tokens # type: ignore[attr-defined]
688
+ set_function.scoring_target_source = scoring_target_source # type: ignore[attr-defined]
689
+ set_function.scoring_target_text = scoring_target_text # type: ignore[attr-defined]
690
+ set_function.answer_received = answer # type: ignore[attr-defined]
691
+ set_function.reference_answer_received = reference_answer # type: ignore[attr-defined]
692
+ set_function.min_logprob_seen = min_logprob_seen # type: ignore[attr-defined]
693
+ return set_function
694
+
695
+ def _compute_mobius(self, context: str, answer: str, reference_answer: str, prompt: str):
696
+ """Compute Mobius coefficients for the masked context game."""
697
+ features, masking_info = self._mask_context(context)
698
+ set_function = self._build_set_function(features, answer, reference_answer, prompt)
699
+ mobius = run_proxyspex(
700
+ set_function,
701
+ features,
702
+ max_order=self.config.interaction_order,
703
+ seed=self.config.random_seed,
704
+ )
705
+ return mobius, features, masking_info, set_function
706
+
707
+ def _index_to_tuple(self, idx: int) -> Tuple[int, ...]:
708
+ """Return a sparse coalition tuple with a single active index."""
709
+ return (idx,)
710
+
711
+ def _tuple_to_indices(self, loc_tuple: Tuple[int, ...]) -> List[int]:
712
+ """Return active feature indices from a sparse coalition tuple."""
713
+ return sorted(loc_tuple)
714
+
715
+ @staticmethod
716
+ def _assert_tuple_int_key(key: Any, object_name: str) -> None:
717
+ assert isinstance(
718
+ key, tuple
719
+ ), f"{object_name} key must be tuple; got type={type(key).__name__} value={key!r}"
720
+ assert all(
721
+ isinstance(i, int) for i in key
722
+ ), f"{object_name} key entries must be int; got value={key!r}"
723
+
724
+ def _validate_mobius_keys(self, mobius_obj: Any, object_name: str) -> Dict[Tuple[int, ...], float]:
725
+ if not isinstance(mobius_obj, dict):
726
+ message = (
727
+ f"{object_name} must be dict with tuple[int,...] keys; "
728
+ f"got type={type(mobius_obj).__name__}"
729
+ )
730
+ print(message)
731
+ raise TypeError(message)
732
+ for key in mobius_obj.keys():
733
+ try:
734
+ self._assert_tuple_int_key(key, object_name)
735
+ except AssertionError as exc:
736
+ message = (
737
+ f"{object_name} contains invalid key. "
738
+ f"type={type(key).__name__} value={key!r} error={exc}"
739
+ )
740
+ print(message)
741
+ raise AssertionError(message) from exc
742
+ return mobius_obj
743
+
744
+ def _validate_interaction_pairs(
745
+ self,
746
+ pairs_obj: Any,
747
+ object_name: str,
748
+ ) -> List[Tuple[Tuple[int, ...], float]]:
749
+ if not isinstance(pairs_obj, list):
750
+ message = (
751
+ f"{object_name} must be list of ((int,...), value) pairs; "
752
+ f"got type={type(pairs_obj).__name__}"
753
+ )
754
+ print(message)
755
+ raise TypeError(message)
756
+
757
+ validated: List[Tuple[Tuple[int, ...], float]] = []
758
+ for idx, item in enumerate(pairs_obj):
759
+ if not (isinstance(item, tuple) and len(item) == 2):
760
+ message = (
761
+ f"{object_name}[{idx}] must be tuple(len=2); "
762
+ f"got type={type(item).__name__} value={item!r}"
763
+ )
764
+ print(message)
765
+ raise TypeError(message)
766
+ key, value = item
767
+ try:
768
+ self._assert_tuple_int_key(key, f"{object_name}[{idx}]")
769
+ except AssertionError as exc:
770
+ message = (
771
+ f"{object_name}[{idx}] has invalid interaction key. "
772
+ f"type={type(key).__name__} value={key!r} error={exc}"
773
+ )
774
+ print(message)
775
+ raise AssertionError(message) from exc
776
+ validated.append((key, float(value)))
777
+ return validated
778
+
779
+ def _build_singletons(
780
+ self, values: Dict[Tuple[int, ...], float], features: Sequence[str], method: str
781
+ ) -> Dict[Tuple[int, ...], AttributionResult]:
782
+ """Wrap singleton values into AttributionResult objects."""
783
+ n = len(features)
784
+ results: Dict[Tuple[int, ...], AttributionResult] = {}
785
+ for idx in range(n):
786
+ loc = self._index_to_tuple(idx)
787
+ value = float(values.get(loc, 0.0))
788
+ results[loc] = AttributionResult(
789
+ feature=features[idx],
790
+ value=value,
791
+ method=method,
792
+ interaction_order=1,
793
+ )
794
+ return results
795
+
796
+ def _build_interactions(
797
+ self,
798
+ interactions: List[Tuple[Tuple[int, ...], float]],
799
+ features: Sequence[str],
800
+ method: str,
801
+ ) -> List[Tuple[Tuple[int, ...], AttributionResult]]:
802
+ """Wrap interaction scores into AttributionResult objects."""
803
+ payload: List[Tuple[Tuple[int, ...], AttributionResult]] = []
804
+ for loc, value in interactions:
805
+ indices = self._tuple_to_indices(loc)
806
+ if len(indices) <= 1:
807
+ continue
808
+ feature_names = tuple(features[i] for i in indices)
809
+ payload.append(
810
+ (
811
+ loc,
812
+ AttributionResult(
813
+ feature=feature_names,
814
+ value=float(value),
815
+ method=method,
816
+ interaction_order=len(indices),
817
+ ),
818
+ )
819
+ )
820
+ return payload
821
+
822
+ def _trim_mobius(
823
+ self, mobius_dict: Dict[Tuple[int, ...], float]
824
+ ) -> Dict[Tuple[int, ...], float]:
825
+ """Keep only the largest Mobius coefficients by magnitude."""
826
+ if len(mobius_dict) <= MAX_MOBIUS_TERMS:
827
+ return mobius_dict
828
+ sorted_items = sorted(mobius_dict.items(), key=lambda x: abs(x[1]), reverse=True)
829
+ return dict(sorted_items[:MAX_MOBIUS_TERMS])
830
+
831
+ def _mobius_to_values_and_interactions(
832
+ self, mobius_dict: Dict[Tuple[int, ...], float], features: Sequence[str]
833
+ ):
834
+ """
835
+ Convert Mobius coefficients into singleton values and pairwise interactions.
836
+ """
837
+ mobius_dict = self._trim_mobius(mobius_dict)
838
+ shapley_values = self._build_singletons(
839
+ mobius_to_shapley(mobius_dict),
840
+ features,
841
+ "shapley",
842
+ )
843
+ banzhaf_values = self._build_singletons(
844
+ mobius_to_banzhaf(mobius_dict),
845
+ features,
846
+ "banzhaf",
847
+ )
848
+ influence_values = self._build_singletons(
849
+ mobius_to_influence(mobius_dict),
850
+ features,
851
+ "influence",
852
+ )
853
+ shapley_pairs = shapley_interactions(
854
+ mobius_dict,
855
+ order=2,
856
+ top_k=self.config.top_k_interactions,
857
+ )
858
+ banzhaf_pairs = banzhaf_interactions(
859
+ mobius_dict,
860
+ order=2,
861
+ top_k=self.config.top_k_interactions,
862
+ )
863
+ influence_pairs = influence_interactions(
864
+ mobius_dict,
865
+ order=2,
866
+ top_k=self.config.top_k_interactions,
867
+ )
868
+
869
+ shapley_ints = self._build_interactions(shapley_pairs, features, "shapley")
870
+ banzhaf_ints = self._build_interactions(banzhaf_pairs, features, "banzhaf")
871
+ influence_ints = self._build_interactions(influence_pairs, features, "influence")
872
+
873
+ return {
874
+ "shapley": {
875
+ "values": shapley_values,
876
+ "interactions": shapley_ints,
877
+ },
878
+ "banzhaf": {
879
+ "values": banzhaf_values,
880
+ "interactions": banzhaf_ints,
881
+ },
882
+ "influence": {
883
+ "values": influence_values,
884
+ "interactions": influence_ints,
885
+ },
886
+ }
887
+
888
+ def _log_influence_debug(
889
+ self,
890
+ mobius_dict: Dict[Tuple[int, ...], float],
891
+ features: Sequence[str],
892
+ values: Dict[Tuple[int, ...], AttributionResult],
893
+ interactions: List[Tuple[Tuple[int, ...], AttributionResult]],
894
+ ) -> None:
895
+ """Print influence internals for debugging in terminal logs."""
896
+ debug_flag = os.getenv("ATTRLLM_INFLUENCE_DEBUG", "1").strip().lower()
897
+ if debug_flag in {"0", "false", "no", "off"}:
898
+ return
899
+
900
+ try:
901
+ top_n = int(os.getenv("ATTRLLM_INFLUENCE_DEBUG_TOPN", "10"))
902
+ except ValueError:
903
+ top_n = 10
904
+ top_n = max(1, top_n)
905
+
906
+ fourier_dict = mobius_to_fourier(mobius_dict)
907
+ top_mobius = sorted(
908
+ mobius_dict.items(),
909
+ key=lambda kv: abs(float(np.real(kv[1]))),
910
+ reverse=True,
911
+ )[:top_n]
912
+ top_fourier = sorted(
913
+ fourier_dict.items(),
914
+ key=lambda kv: abs(float(np.real(kv[1]))),
915
+ reverse=True,
916
+ )[:top_n]
917
+
918
+ singleton_rows: List[Tuple[int, str, float]] = []
919
+ for loc, result in values.items():
920
+ idxs = self._tuple_to_indices(loc)
921
+ if len(idxs) != 1:
922
+ continue
923
+ idx = idxs[0]
924
+ if 0 <= idx < len(features):
925
+ singleton_rows.append((idx, str(features[idx]), float(result.value)))
926
+ singleton_rows.sort(key=lambda row: abs(row[2]), reverse=True)
927
+ singleton_rows = singleton_rows[:top_n]
928
+
929
+ interaction_rows: List[Tuple[Tuple[int, ...], str, float]] = []
930
+ for loc, result in interactions:
931
+ idxs = self._tuple_to_indices(loc)
932
+ if len(idxs) != 2:
933
+ continue
934
+ labels = [str(features[i]) for i in idxs if 0 <= i < len(features)]
935
+ interaction_rows.append((tuple(idxs), " × ".join(labels), float(result.value)))
936
+ interaction_rows.sort(key=lambda row: abs(row[2]), reverse=True)
937
+ interaction_rows = interaction_rows[:top_n]
938
+
939
+ lines = [
940
+ "[influence-debug] "
941
+ f"feature_count={len(features)} mobius_terms={len(mobius_dict)} fourier_terms={len(fourier_dict)} top_n={top_n}",
942
+ "[influence-debug] top_mobius_terms="
943
+ + "; ".join([f"{loc}:{float(np.real(val)):.6f}" for loc, val in top_mobius]),
944
+ "[influence-debug] top_fourier_terms="
945
+ + "; ".join([f"{loc}:{float(np.real(val)):.6f}" for loc, val in top_fourier]),
946
+ "[influence-debug] top_singleton_influence="
947
+ + "; ".join([f"idx={idx} token={token!r} value={val:.6f}" for idx, token, val in singleton_rows]),
948
+ "[influence-debug] top_pair_influence="
949
+ + "; ".join([f"idx={idxs} pair={pair!r} value={val:.6f}" for idxs, pair, val in interaction_rows]),
950
+ ]
951
+ debug_text = "\n".join(lines)
952
+ print(debug_text, flush=True)
953
+ logger.info("%s", debug_text)
954
+
955
+ def _format_feature_payload(
956
+ self, singletons: Dict[Tuple[int, ...], AttributionResult], features: Sequence[str]
957
+ ) -> List[Dict[str, Any]]:
958
+ """Serialize singleton attributions for API output."""
959
+ payload: List[Dict[str, Any]] = []
960
+ n = len(features)
961
+ for idx in range(n):
962
+ loc = self._index_to_tuple(idx)
963
+ result = singletons.get(loc)
964
+ value = 0.0 if result is None else result.value
965
+ payload.append(
966
+ {
967
+ "index": idx,
968
+ "feature": features[idx],
969
+ "value": value,
970
+ "method": result.method if result else self.config.method,
971
+ }
972
+ )
973
+ return payload
974
+
975
+ def _format_interaction_payload(
976
+ self, interactions: List[Tuple[Tuple[int, ...], AttributionResult]]
977
+ ) -> List[Dict[str, Any]]:
978
+ """Serialize interaction attributions for API output."""
979
+ payload: List[Dict[str, Any]] = []
980
+ for loc, result in interactions:
981
+ indices = self._tuple_to_indices(loc)
982
+ feature_list = list(result.feature) if isinstance(result.feature, tuple) else [result.feature]
983
+ feature_label = " × ".join(feature_list)
984
+
985
+ payload.append(
986
+ {
987
+ "indices": indices,
988
+ "features": feature_label,
989
+ "feature_list": feature_list,
990
+ "value": result.value,
991
+ "method": result.method,
992
+ "order": result.interaction_order,
993
+ }
994
+ )
995
+ return payload
996
+
997
+ def compute_attributions(
998
+ self,
999
+ context: str,
1000
+ answer: str,
1001
+ reference_answer: str = "",
1002
+ prompt: str = "",
1003
+ debug: bool = False,
1004
+ debug_coalitions: Any | None = None,
1005
+ ) -> Dict[str, Any]:
1006
+ """Compute singleton values, interactions, and heatmap-ready output."""
1007
+ mobius, features, masking_info, set_function = self._compute_mobius(
1008
+ context,
1009
+ answer,
1010
+ reference_answer,
1011
+ prompt,
1012
+ )
1013
+ aggregated = self._mobius_to_values_and_interactions(mobius, features)
1014
+ method_payload = aggregated[self.config.method]
1015
+ if self.config.method == "influence":
1016
+ self._log_influence_debug(
1017
+ mobius,
1018
+ features,
1019
+ method_payload["values"],
1020
+ method_payload["interactions"],
1021
+ )
1022
+ feature_items = self._format_feature_payload(method_payload["values"], features)
1023
+ interaction_items = self._format_interaction_payload(method_payload["interactions"])
1024
+ interactions_only = method_payload["interactions"]
1025
+ heatmap_attrs = (
1026
+ [item[1] for item in interactions_only]
1027
+ if interactions_only
1028
+ else list(method_payload["values"].values())
1029
+ )
1030
+ heatmap = convert_to_heatmap_data(heatmap_attrs, feature_order=features)
1031
+
1032
+ debug_scores: List[Dict[str, Any]] = []
1033
+ score_full = None
1034
+ score_empty = None
1035
+ score_full_details: Dict[str, Any] | None = None
1036
+ score_empty_details: Dict[str, Any] | None = None
1037
+ try:
1038
+ if hasattr(set_function, "score_details"):
1039
+ all_sel = [1] * len(features)
1040
+ empty_sel = [0] * len(features)
1041
+ score_full_details = set_function.score_details(all_sel)
1042
+ score_empty_details = set_function.score_details(empty_sel)
1043
+ score_full = score_full_details.get("score") if score_full_details else None
1044
+ score_empty = score_empty_details.get("score") if score_empty_details else None
1045
+ except Exception as exc:
1046
+ logger.warning("score_full/empty computation failed: %s", exc)
1047
+ if debug and hasattr(set_function, "score_details"):
1048
+ n_features = len(features)
1049
+ selections: List[Tuple[str, Tuple[int, ...], List[int]]] = []
1050
+ selections.append(("ALL", tuple(1 for _ in range(n_features)), list(range(n_features))))
1051
+ selections.append(("EMPTY", tuple(0 for _ in range(n_features)), []))
1052
+
1053
+ coalitions: List[List[int]] = []
1054
+ if isinstance(debug_coalitions, list):
1055
+ if debug_coalitions and all(isinstance(x, int) for x in debug_coalitions):
1056
+ coalitions = [[int(x)] for x in debug_coalitions]
1057
+ else:
1058
+ for entry in debug_coalitions:
1059
+ if isinstance(entry, (list, tuple)):
1060
+ coalitions.append([int(x) for x in entry])
1061
+ if coalitions:
1062
+ for indices in coalitions:
1063
+ indices = [idx for idx in indices if 0 <= idx < n_features]
1064
+ mask = [0] * n_features
1065
+ for idx in indices:
1066
+ mask[idx] = 1
1067
+ selections.append((f"COALITION {indices}", tuple(mask), indices))
1068
+ else:
1069
+ for idx in range(min(5, n_features)):
1070
+ mask = [0] * n_features
1071
+ mask[idx] = 1
1072
+ selections.append((f"SINGLETON {idx}", tuple(mask), [idx]))
1073
+
1074
+ for label, selection, indices in selections:
1075
+ details = set_function.score_details(selection)
1076
+ record = {
1077
+ "label": label,
1078
+ "indices": indices,
1079
+ **details,
1080
+ }
1081
+ debug_scores.append(record)
1082
+ print(
1083
+ f"[debug_scores] {label} indices={indices} "
1084
+ f"score={details['score']:.4f} "
1085
+ f"total_logprob={details['total_logprob']:.4f} "
1086
+ f"avg_nll={details['avg_nll']:.4f} ppl={details['ppl']:.4f} "
1087
+ f"target_len={details['target_len']}"
1088
+ )
1089
+
1090
+ y_full = getattr(set_function, "y_full", "")
1091
+ y_len_tokens = getattr(set_function, "y_len_tokens", 0)
1092
+ scoring_target_source = getattr(set_function, "scoring_target_source", "model_output")
1093
+ scoring_target_text = getattr(set_function, "scoring_target_text", y_full)
1094
+ reference_answer_received = getattr(set_function, "reference_answer_received", "")
1095
+ answer_received = getattr(set_function, "answer_received", "")
1096
+
1097
+ def _summarize_text(value: Any, preview_chars: int = 200) -> Dict[str, Any]:
1098
+ text = str(value or "")
1099
+ preview = text[:preview_chars]
1100
+ if len(text) > preview_chars:
1101
+ preview = preview + "..."
1102
+ return {
1103
+ "length": len(text),
1104
+ "preview": preview,
1105
+ }
1106
+
1107
+ min_logprob_seen = None
1108
+ if hasattr(set_function, "min_logprob_seen"):
1109
+ try:
1110
+ min_logprob_seen = set_function.min_logprob_seen.get("value") # type: ignore[attr-defined]
1111
+ except Exception:
1112
+ min_logprob_seen = None
1113
+
1114
+ return {
1115
+ "method": self.config.method,
1116
+ "features": feature_items,
1117
+ "interactions": interaction_items,
1118
+ "masking": asdict(masking_info),
1119
+ "heatmap": heatmap,
1120
+ "debug_scores": debug_scores or None,
1121
+ "scalarizer_used": self.config.scalarizer,
1122
+ "embedding_model": self.config.embedding_model,
1123
+ "y_full": y_full,
1124
+ "y_len_tokens": y_len_tokens,
1125
+ "scoring_target_source": scoring_target_source,
1126
+ "scoring_target_text": scoring_target_text,
1127
+ "reference_answer_received": _summarize_text(reference_answer_received),
1128
+ "answer_received": _summarize_text(answer_received),
1129
+ "score_full": score_full,
1130
+ "score_empty": score_empty,
1131
+ "logprob_full": score_full_details.get("logprob") if score_full_details else None,
1132
+ "logprob_empty": score_empty_details.get("logprob") if score_empty_details else None,
1133
+ "avg_nll_full": score_full_details.get("avg_nll") if score_full_details else None,
1134
+ "avg_nll_empty": score_empty_details.get("avg_nll") if score_empty_details else None,
1135
+ "ppl_full": score_full_details.get("ppl") if score_full_details else None,
1136
+ "ppl_empty": score_empty_details.get("ppl") if score_empty_details else None,
1137
+ "min_logprob_seen": min_logprob_seen,
1138
+ }
1139
+
1140
+ def compute_interactions(
1141
+ self,
1142
+ context: str,
1143
+ answer: str,
1144
+ order: int = 2,
1145
+ prompt: str = "",
1146
+ ) -> List[Tuple[Tuple[int, ...], float]]:
1147
+ """Compute interaction-only payload for a requested order."""
1148
+ mobius, _, _, _ = self._compute_mobius(context, answer, "", prompt)
1149
+ mobius = self._validate_mobius_keys(self._trim_mobius(mobius), "mobius")
1150
+ if self.config.method == "shapley":
1151
+ pairs = shapley_interactions(mobius, order=order, top_k=self.config.top_k_interactions)
1152
+ elif self.config.method == "banzhaf":
1153
+ pairs = banzhaf_interactions(mobius, order=order, top_k=self.config.top_k_interactions)
1154
+ elif self.config.method == "influence":
1155
+ pairs = influence_interactions(mobius, order=order, top_k=self.config.top_k_interactions)
1156
+ else: # defensive; AttributorConfig should already validate this
1157
+ raise ValueError(f"Unsupported attribution method: {self.config.method}")
1158
+ validated_pairs = self._validate_interaction_pairs(pairs, "interactions")
1159
+ if self.config.method == "influence":
1160
+ try:
1161
+ top_n = int(os.getenv("ATTRLLM_INFLUENCE_DEBUG_TOPN", "10"))
1162
+ except ValueError:
1163
+ top_n = 10
1164
+ top_n = max(1, top_n)
1165
+ top_pairs = sorted(validated_pairs, key=lambda kv: abs(float(kv[1])), reverse=True)[:top_n]
1166
+ debug_line = (
1167
+ "[influence-debug][interactions] "
1168
+ f"order={order} mobius_terms={len(mobius)} returned_pairs={len(validated_pairs)} "
1169
+ f"top_pairs={top_pairs}"
1170
+ )
1171
+ print(debug_line, flush=True)
1172
+ logger.info("%s", debug_line)
1173
+ return validated_pairs
1174
+
1175
+ def explain_sample(
1176
+ self,
1177
+ dataset: str,
1178
+ ex_id: str,
1179
+ method: str | None = None,
1180
+ ) -> Dict[str, Any]:
1181
+ """Fetch a dataset example and return attribution results."""
1182
+ if method and method.lower() != self.config.method:
1183
+ self.config.method = method.lower()
1184
+ example = get_example_by_id(dataset, ex_id)
1185
+ context = example.get("context")
1186
+ answer = example.get("answer")
1187
+ if context is None or answer is None:
1188
+ raise KeyError("Dataset example must include 'context' and 'answer' fields.")
1189
+ attributions = self.compute_attributions(context, answer)
1190
+ return {
1191
+ "dataset": dataset,
1192
+ "example_id": ex_id,
1193
+ "question": example.get("question"),
1194
+ "attribution": attributions,
1195
+ }
1196
+
1197
+
1198
+ def compute_attributions(
1199
+ model: BaseModel,
1200
+ context: str,
1201
+ answer: str,
1202
+ method: str,
1203
+ mask_level: str = "sentence",
1204
+ interaction_order: int = 3,
1205
+ prompt: str | None = None,
1206
+ debug: bool = False,
1207
+ debug_coalitions: Any | None = None,
1208
+ scalarizer: str = "logprob",
1209
+ embedding_model: str | None = None,
1210
+ reference_answer: str | None = None,
1211
+ ) -> Dict[str, Any]:
1212
+ """Convenience wrapper for full attribution payloads."""
1213
+ config = AttributorConfig(
1214
+ mask_level=mask_level,
1215
+ method=method,
1216
+ interaction_order=interaction_order,
1217
+ scalarizer=scalarizer,
1218
+ embedding_model=embedding_model,
1219
+ )
1220
+ _ = prompt # prompt is reserved for future template control
1221
+ return Attributor(model, config).compute_attributions(
1222
+ context,
1223
+ answer,
1224
+ reference_answer=reference_answer or "",
1225
+ prompt=prompt or "",
1226
+ debug=debug,
1227
+ debug_coalitions=debug_coalitions,
1228
+ )
1229
+
1230
+
1231
+ def compute_interactions(
1232
+ model: BaseModel,
1233
+ context: str,
1234
+ answer: str,
1235
+ order: int = 2,
1236
+ method: str = "shapley",
1237
+ mask_level: str = "sentence",
1238
+ prompt: str | None = None,
1239
+ scalarizer: str = "logprob",
1240
+ embedding_model: str | None = None,
1241
+ ) -> List[Tuple[Tuple[int, ...], float]]:
1242
+ """Convenience wrapper for interaction-only queries."""
1243
+ config = AttributorConfig(
1244
+ mask_level=mask_level,
1245
+ method=method,
1246
+ interaction_order=order,
1247
+ scalarizer=scalarizer,
1248
+ embedding_model=embedding_model,
1249
+ )
1250
+ return Attributor(model, config).compute_interactions(context, answer, order=order, prompt=prompt or "")
1251
+
1252
+
1253
+ def explain_sample(
1254
+ dataset: str,
1255
+ ex_id: str,
1256
+ model: BaseModel,
1257
+ method: str = "shapley",
1258
+ mask_level: str = "sentence",
1259
+ ) -> Dict[str, Any]:
1260
+ """Fetch a dataset example and run attribution."""
1261
+ config = AttributorConfig(mask_level=mask_level, method=method)
1262
+ return Attributor(model, config).explain_sample(dataset, ex_id)
attribution/image_masker.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Sequence, Tuple
5
+
6
+ import numpy as np
7
+ from PIL import Image, ImageFilter
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ImageRegion:
12
+ index: int
13
+ bbox: Tuple[int, int, int, int]
14
+ label: str
15
+ mask: np.ndarray | None = None
16
+
17
+
18
+ def _ensure_rgb(image: Image.Image) -> Image.Image:
19
+ if image.mode == "RGB":
20
+ return image
21
+ return image.convert("RGB")
22
+
23
+
24
+ def _region_mask(region: ImageRegion, shape: Tuple[int, int]) -> np.ndarray:
25
+ if region.mask is not None:
26
+ return region.mask
27
+ height, width = shape
28
+ x0, y0, x1, y1 = region.bbox
29
+ mask = np.zeros((height, width), dtype=bool)
30
+ x0 = max(0, min(width, x0))
31
+ x1 = max(0, min(width, x1))
32
+ y0 = max(0, min(height, y0))
33
+ y1 = max(0, min(height, y1))
34
+ if x1 > x0 and y1 > y0:
35
+ mask[y0:y1, x0:x1] = True
36
+ return mask
37
+
38
+
39
+ def _mask_to_bbox(mask: np.ndarray) -> Tuple[int, int, int, int]:
40
+ ys, xs = np.where(mask)
41
+ if ys.size == 0 or xs.size == 0:
42
+ return (0, 0, 0, 0)
43
+ y0, y1 = int(ys.min()), int(ys.max()) + 1
44
+ x0, x1 = int(xs.min()), int(xs.max()) + 1
45
+ return (x0, y0, x1, y1)
46
+
47
+
48
+ def _patch_grid_edges(size: int, bins: int) -> List[int]:
49
+ bins = max(1, int(bins))
50
+ edges = np.linspace(0, size, bins + 1)
51
+ return [int(round(val)) for val in edges]
52
+
53
+
54
+ def supports_superpixel() -> bool:
55
+ try:
56
+ from skimage.segmentation import slic # type: ignore # noqa: F401
57
+ except Exception:
58
+ return False
59
+ return True
60
+
61
+
62
+ def build_regions(
63
+ image: Image.Image,
64
+ mask_level: str,
65
+ grid_size: int | None = None,
66
+ ) -> List[ImageRegion]:
67
+ """Build image regions as either a patch grid or superpixel segments."""
68
+ image = _ensure_rgb(image)
69
+ width, height = image.size
70
+ mask_level = (mask_level or "patch").lower()
71
+
72
+ if mask_level == "patch":
73
+ grid = int(grid_size or 8)
74
+ x_edges = _patch_grid_edges(width, grid)
75
+ y_edges = _patch_grid_edges(height, grid)
76
+ regions: List[ImageRegion] = []
77
+ idx = 0
78
+ for r in range(len(y_edges) - 1):
79
+ for c in range(len(x_edges) - 1):
80
+ x0, x1 = x_edges[c], x_edges[c + 1]
81
+ y0, y1 = y_edges[r], y_edges[r + 1]
82
+ label = f"patch_{r}_{c}"
83
+ regions.append(ImageRegion(idx, (x0, y0, x1, y1), label))
84
+ idx += 1
85
+ return regions
86
+
87
+ if mask_level == "superpixel":
88
+ if not supports_superpixel():
89
+ raise ValueError(
90
+ "Superpixel masking requires scikit-image. Install it or use mask_level='patch'."
91
+ )
92
+ from skimage.segmentation import slic # type: ignore
93
+
94
+ np_img = np.asarray(image)
95
+ n_segments = max(25, int((width * height) / 4096))
96
+ segments = slic(np_img, n_segments=n_segments, compactness=10.0, start_label=0)
97
+ regions = []
98
+ idx = 0
99
+ for seg_id in np.unique(segments):
100
+ mask = segments == seg_id
101
+ if not mask.any():
102
+ continue
103
+ bbox = _mask_to_bbox(mask)
104
+ label = f"superpixel_{int(seg_id)}"
105
+ regions.append(ImageRegion(idx, bbox, label, mask=mask))
106
+ idx += 1
107
+ return regions
108
+
109
+ raise ValueError(f"Unsupported mask_level: {mask_level}")
110
+
111
+
112
+ def apply_image_mask(
113
+ image: Image.Image,
114
+ regions: Sequence[ImageRegion],
115
+ coalition_mask: Sequence[int],
116
+ mode: str = "blur",
117
+ ) -> Image.Image:
118
+ """
119
+ Apply a coalition mask to an image by hiding inactive regions.
120
+ """
121
+ image = _ensure_rgb(image)
122
+ arr = np.asarray(image).copy()
123
+ height, width = arr.shape[:2]
124
+ mode = (mode or "blur").lower()
125
+
126
+ blur_arr = None
127
+ mean_color = None
128
+ if mode == "blur":
129
+ blur_arr = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=12)))
130
+ elif mode == "mean":
131
+ mean_color = arr.mean(axis=(0, 1)).astype(np.uint8)
132
+ elif mode == "gray":
133
+ mean_color = np.array([128, 128, 128], dtype=np.uint8)
134
+ else:
135
+ raise ValueError(f"Unsupported masking mode: {mode}")
136
+
137
+ if len(coalition_mask) != len(regions):
138
+ raise ValueError("Coalition mask length must match number of regions.")
139
+
140
+ for idx, region in enumerate(regions):
141
+ if int(coalition_mask[idx]):
142
+ continue
143
+ mask = _region_mask(region, (height, width))
144
+ if not mask.any():
145
+ continue
146
+ if mode == "blur":
147
+ arr[mask] = blur_arr[mask] # type: ignore[index]
148
+ else:
149
+ arr[mask] = mean_color # type: ignore[assignment]
150
+
151
+ return Image.fromarray(arr)
152
+
153
+
154
+ def render_overlay(
155
+ image: Image.Image,
156
+ regions: Sequence[ImageRegion],
157
+ values: Sequence[float],
158
+ *,
159
+ alpha: float = 0.6,
160
+ ) -> Image.Image:
161
+ """Render a transparent overlay image from region attribution values."""
162
+ image = _ensure_rgb(image)
163
+ width, height = image.size
164
+ overlay = np.zeros((height, width, 4), dtype=np.uint8)
165
+ max_abs = max((abs(v) for v in values), default=0.0) or 1.0
166
+
167
+ for region, value in zip(regions, values):
168
+ mask = _region_mask(region, (height, width))
169
+ if not mask.any():
170
+ continue
171
+ norm = min(1.0, abs(value) / max_abs)
172
+ if norm <= 0:
173
+ continue
174
+ if value >= 0:
175
+ rgb = np.array([1, 109, 1], dtype=np.uint8)
176
+ else:
177
+ rgb = np.array([221, 19, 19], dtype=np.uint8)
178
+ opacity = int(round(255 * alpha * norm))
179
+ overlay[mask, :3] = rgb
180
+ overlay[mask, 3] = opacity
181
+
182
+ return Image.fromarray(overlay, mode="RGBA")
attribution/masker.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Callable, List, Sequence, Tuple
6
+
7
+ try:
8
+ import spacy
9
+ except ImportError: # pragma: no cover - optional dependency
10
+ spacy = None
11
+
12
+ try:
13
+ import nltk
14
+ from nltk.tokenize import sent_tokenize
15
+ except ImportError: # pragma: no cover - optional dependency
16
+ nltk = None
17
+ sent_tokenize = None
18
+
19
+
20
+ MaskerCallable = Callable[[str], List[Tuple[str, Tuple[int, int]]]]
21
+
22
+ _SPACY_NLP = None
23
+
24
+
25
+ @dataclass
26
+ class MaskingInfo:
27
+ """Metadata describing how a piece of text was masked."""
28
+
29
+ mask_level: str
30
+ mask_indices: List[int]
31
+ feature_spans: List[Tuple[int, int]]
32
+
33
+
34
+ def _load_spacy_model():
35
+ global _SPACY_NLP
36
+ if spacy is None:
37
+ return None
38
+ if _SPACY_NLP is not None:
39
+ return _SPACY_NLP
40
+ try:
41
+ _SPACY_NLP = spacy.load("en_core_web_sm")
42
+ except OSError: # model might not be installed
43
+ _SPACY_NLP = spacy.blank("en")
44
+ if not _SPACY_NLP.has_pipe("sentencizer"):
45
+ _SPACY_NLP.add_pipe("sentencizer")
46
+ return _SPACY_NLP
47
+
48
+
49
+ def _word_masker(text: str) -> List[Tuple[str, Tuple[int, int]]]:
50
+ pattern = re.compile(r"\S+")
51
+ spans = []
52
+ for match in pattern.finditer(text):
53
+ start, end = match.span()
54
+ spans.append((text[start:end], (start, end)))
55
+ return spans
56
+
57
+
58
+ def _sentence_masker_spacy(text: str) -> List[Tuple[str, Tuple[int, int]]]:
59
+ nlp = _load_spacy_model()
60
+ if nlp is None:
61
+ return []
62
+ doc = nlp(text)
63
+ spans = []
64
+ for sent in doc.sents:
65
+ spans.append((sent.text.strip(), (sent.start_char, sent.end_char)))
66
+ return spans
67
+
68
+
69
+ def _sentence_masker(text: str) -> List[Tuple[str, Tuple[int, int]]]:
70
+ spans = _sentence_masker_spacy(text)
71
+ if spans:
72
+ return spans
73
+ if sent_tokenize is not None:
74
+ sentences = sent_tokenize(text)
75
+ else:
76
+ regex = re.compile(r"[^.!?]+[.!?]?\s*", re.MULTILINE)
77
+ sentences = [m.group().strip() for m in regex.finditer(text)]
78
+ spans = []
79
+ cursor = 0
80
+ for sentence in sentences:
81
+ if not sentence:
82
+ continue
83
+ idx = text.find(sentence, cursor)
84
+ if idx == -1:
85
+ idx = cursor
86
+ cursor = idx + len(sentence)
87
+ spans.append((sentence.strip(), (idx, idx + len(sentence))))
88
+ return spans
89
+
90
+
91
+ def _paragraph_masker(text: str) -> List[Tuple[str, Tuple[int, int]]]:
92
+ paragraphs: Sequence[str] = re.split(r"\n\s*\n", text.strip())
93
+ spans: List[Tuple[str, Tuple[int, int]]] = []
94
+ cursor = 0
95
+ for para in paragraphs:
96
+ normalized = para.strip()
97
+ if not normalized:
98
+ continue
99
+ idx = text.find(para, cursor)
100
+ if idx == -1:
101
+ idx = text.find(normalized, cursor)
102
+ if idx == -1:
103
+ continue
104
+ para = normalized
105
+ cursor = idx + len(para)
106
+ spans.append((normalized, (idx, idx + len(para))))
107
+ if not spans and text.strip():
108
+ spans.append((text.strip(), (0, len(text))))
109
+ return spans
110
+
111
+
112
+ def get_masker(level: str) -> MaskerCallable:
113
+ """
114
+ Return a masker callable for the requested granularity level.
115
+
116
+ Parameters
117
+ ----------
118
+ level:
119
+ One of ``"word"``, ``"sentence"``, or ``"paragraph"``.
120
+ """
121
+ normalized = (level or "").strip().lower()
122
+ if normalized == "word":
123
+ return _word_masker
124
+ if normalized == "sentence":
125
+ return _sentence_masker
126
+ if normalized == "paragraph":
127
+ return _paragraph_masker
128
+ raise ValueError(f"Unsupported mask level: {level}")
129
+
130
+
131
+ def mask_text(text: str, masker: MaskerCallable) -> Tuple[List[str], MaskingInfo]:
132
+ """
133
+ Chunk text into features according to the provided masker.
134
+
135
+ Returns
136
+ -------
137
+ features:
138
+ The textual surface form of each chunk.
139
+ masking_info:
140
+ Metadata describing the spans and mask indices.
141
+ """
142
+ spans_with_text = masker(text)
143
+ features = [entry[0] for entry in spans_with_text]
144
+ feature_spans = [entry[1] for entry in spans_with_text]
145
+ mask_indices = list(range(len(features)))
146
+ level = getattr(masker, "__name__", "custom")
147
+ info = MaskingInfo(mask_level=level, mask_indices=mask_indices, feature_spans=feature_spans)
148
+ return features, info
149
+
attribution/mm_game.py ADDED
@@ -0,0 +1,922 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import io
5
+ import math
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Any, Dict, List, Sequence, Tuple
8
+
9
+ from PIL import Image
10
+
11
+ from attribution.image_masker import ImageRegion, apply_image_mask, build_regions, render_overlay
12
+ from attribution.masker import MaskingInfo, get_masker, mask_text
13
+ from attribution.proxyspex import run_proxyspex
14
+ from attribution.utils import banzhaf_interactions, mobius_to_banzhaf, mobius_to_shapley, shapley_interactions
15
+
16
+ try:
17
+ from loader.models import BaseModel
18
+ except ImportError: # pragma: no cover
19
+ BaseModel = Any # type: ignore
20
+
21
+
22
+ SUPPORTED_METHODS = {"shapley", "banzhaf"}
23
+
24
+
25
+ def _join_prompt_answer(prompt: str, answer: str) -> str:
26
+ if not prompt:
27
+ return answer
28
+ if not answer:
29
+ return prompt
30
+ if prompt[-1].isspace() or answer[0].isspace():
31
+ return prompt + answer
32
+ return prompt + " " + answer
33
+
34
+
35
+ def _inject_image_placeholder(prompt: str, placeholder: str) -> str:
36
+ prompt = prompt or ""
37
+ placeholder = placeholder or "<image>"
38
+ known_tokens = (placeholder, "<image>", "<|image_pad|>", "<img>")
39
+ for token in known_tokens:
40
+ if token and token in prompt:
41
+ return prompt
42
+ return f"{placeholder}\n{prompt}".strip()
43
+
44
+
45
+ def _resolve_image_placeholder(tokenizer: Any, fallback: str) -> str:
46
+ for attr in ("image_placeholder", "image_token", "image_pad_token"):
47
+ placeholder = getattr(tokenizer, attr, None)
48
+ if isinstance(placeholder, str) and placeholder:
49
+ return placeholder
50
+ additional_tokens = list(getattr(tokenizer, "additional_special_tokens", None) or [])
51
+ for token in additional_tokens:
52
+ if "image_pad" in token:
53
+ return token
54
+ for token in additional_tokens:
55
+ if "image" in token:
56
+ return token
57
+ return fallback or "<image>"
58
+
59
+
60
+ def _decode_image_b64(image_b64: str) -> Image.Image:
61
+ if not image_b64:
62
+ raise ValueError("image_b64 must be provided")
63
+ if "," in image_b64:
64
+ image_b64 = image_b64.split(",", 1)[1]
65
+ raw = base64.b64decode(image_b64)
66
+ return Image.open(io.BytesIO(raw)).convert("RGB")
67
+
68
+
69
+ def _encode_image_b64(image: Image.Image) -> str:
70
+ buffer = io.BytesIO()
71
+ image.save(buffer, format="PNG")
72
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
73
+
74
+
75
+ def _score_from_prompt_logprobs(
76
+ tokenizer: Any,
77
+ base_prompt: str,
78
+ full_prompt: str,
79
+ prompt_logprobs: Any,
80
+ ) -> Dict[str, Any]:
81
+ if prompt_logprobs is None:
82
+ raise RuntimeError("vLLM did not return prompt_logprobs for multimodal scoring.")
83
+
84
+ base_ids = tokenizer(base_prompt, add_special_tokens=False)["input_ids"]
85
+ full_ids = tokenizer(full_prompt, add_special_tokens=False)["input_ids"]
86
+ if not full_ids or len(full_ids) <= len(base_ids):
87
+ raise ValueError("Prompt did not include target tokens.")
88
+
89
+ if len(prompt_logprobs) == len(full_ids) + 1:
90
+ prompt_logprobs = prompt_logprobs[1:]
91
+ elif len(prompt_logprobs) != len(full_ids):
92
+ length = min(len(prompt_logprobs), len(full_ids))
93
+ prompt_logprobs = prompt_logprobs[:length]
94
+ full_ids = full_ids[:length]
95
+ if len(full_ids) <= len(base_ids):
96
+ raise ValueError("Prompt did not include target tokens.")
97
+
98
+ ref_ids = full_ids[len(base_ids) :]
99
+ ref_len = len(ref_ids)
100
+
101
+ def _to_float(value: Any) -> float:
102
+ return float(getattr(value, "logprob", value))
103
+
104
+ seq_lp = 0.0
105
+ token_logprobs: List[float] = []
106
+ for offset, token_id in enumerate(ref_ids):
107
+ pos = len(base_ids) + offset
108
+ if pos >= len(prompt_logprobs):
109
+ token_lp = -20.0
110
+ else:
111
+ cand = prompt_logprobs[pos] or {}
112
+ if token_id in cand:
113
+ token_lp = _to_float(cand[token_id])
114
+ elif cand:
115
+ token_lp = min(_to_float(v) for v in cand.values()) - 5.0
116
+ else:
117
+ token_lp = -20.0
118
+ seq_lp += token_lp
119
+ token_logprobs.append(token_lp)
120
+
121
+ avg_lp = seq_lp / ref_len if ref_len else float("-inf")
122
+ ppl = math.exp(-avg_lp) if ref_len else float("inf")
123
+ return {
124
+ "avg_logprob": avg_lp,
125
+ "perplexity": ppl,
126
+ "target_len": ref_len,
127
+ "sequence_logprobs": [seq_lp],
128
+ "token_logprobs": [token_logprobs],
129
+ }
130
+
131
+
132
+ def _build_prompt_candidates(
133
+ tokenizer: Any,
134
+ prompt_text: str,
135
+ answer: str,
136
+ placeholder: str,
137
+ ) -> List[Tuple[str, str]]:
138
+ candidate_prompts: List[Tuple[str, str]] = []
139
+ tokenizer_placeholder = _resolve_image_placeholder(tokenizer, placeholder)
140
+ prompt_with_image = _inject_image_placeholder(prompt_text, tokenizer_placeholder)
141
+
142
+ if hasattr(tokenizer, "apply_chat_template"):
143
+ try:
144
+ content: List[Dict[str, Any]] = [{"type": "image"}]
145
+ if prompt_text:
146
+ content.append({"type": "text", "text": prompt_text})
147
+ messages = [{"role": "user", "content": content}]
148
+ base_prompt = tokenizer.apply_chat_template(
149
+ messages,
150
+ tokenize=False,
151
+ add_generation_prompt=True,
152
+ )
153
+ full_prompt = tokenizer.apply_chat_template(
154
+ messages + [{"role": "assistant", "content": answer}],
155
+ tokenize=False,
156
+ add_generation_prompt=False,
157
+ )
158
+ candidate_prompts.append((base_prompt, full_prompt))
159
+ except Exception:
160
+ pass
161
+ try:
162
+ messages = [{"role": "user", "content": prompt_with_image}]
163
+ base_prompt = tokenizer.apply_chat_template(
164
+ messages,
165
+ tokenize=False,
166
+ add_generation_prompt=True,
167
+ )
168
+ full_prompt = tokenizer.apply_chat_template(
169
+ messages + [{"role": "assistant", "content": answer}],
170
+ tokenize=False,
171
+ add_generation_prompt=False,
172
+ )
173
+ candidate_prompts.append((base_prompt, full_prompt))
174
+ except Exception:
175
+ pass
176
+
177
+ candidate_prompts.append((prompt_with_image, _join_prompt_answer(prompt_with_image, answer)))
178
+ return candidate_prompts
179
+
180
+
181
+ def _build_prompt_candidates_batch(
182
+ tokenizer: Any,
183
+ prompts: Sequence[str],
184
+ answer: str,
185
+ placeholder: str,
186
+ ) -> List[Tuple[List[str], List[str]]]:
187
+ prompt_texts = [prompt or "" for prompt in prompts]
188
+ tokenizer_placeholder = _resolve_image_placeholder(tokenizer, placeholder)
189
+ prompt_with_image_list = [
190
+ _inject_image_placeholder(prompt_text, tokenizer_placeholder)
191
+ for prompt_text in prompt_texts
192
+ ]
193
+ candidates: List[Tuple[List[str], List[str]]] = []
194
+
195
+ if hasattr(tokenizer, "apply_chat_template"):
196
+ try:
197
+ base_prompts: List[str] = []
198
+ full_prompts: List[str] = []
199
+ for prompt_text in prompt_texts:
200
+ content: List[Dict[str, Any]] = [{"type": "image"}]
201
+ if prompt_text:
202
+ content.append({"type": "text", "text": prompt_text})
203
+ messages = [{"role": "user", "content": content}]
204
+ base_prompts.append(
205
+ tokenizer.apply_chat_template(
206
+ messages,
207
+ tokenize=False,
208
+ add_generation_prompt=True,
209
+ )
210
+ )
211
+ full_prompts.append(
212
+ tokenizer.apply_chat_template(
213
+ messages + [{"role": "assistant", "content": answer}],
214
+ tokenize=False,
215
+ add_generation_prompt=False,
216
+ )
217
+ )
218
+ candidates.append((base_prompts, full_prompts))
219
+ except Exception:
220
+ pass
221
+ try:
222
+ base_prompts = []
223
+ full_prompts = []
224
+ for prompt_with_image in prompt_with_image_list:
225
+ messages = [{"role": "user", "content": prompt_with_image}]
226
+ base_prompts.append(
227
+ tokenizer.apply_chat_template(
228
+ messages,
229
+ tokenize=False,
230
+ add_generation_prompt=True,
231
+ )
232
+ )
233
+ full_prompts.append(
234
+ tokenizer.apply_chat_template(
235
+ messages + [{"role": "assistant", "content": answer}],
236
+ tokenize=False,
237
+ add_generation_prompt=False,
238
+ )
239
+ )
240
+ candidates.append((base_prompts, full_prompts))
241
+ except Exception:
242
+ pass
243
+
244
+ base_prompts = prompt_with_image_list
245
+ full_prompts = [_join_prompt_answer(prompt_with_image, answer) for prompt_with_image in base_prompts]
246
+ candidates.append((base_prompts, full_prompts))
247
+ return candidates
248
+
249
+
250
+ def _score_multimodal_vllm(model: BaseModel, prompt: str, answer: str, image: Image.Image) -> Dict[str, Any]:
251
+ info = getattr(model, "info", None)
252
+ if callable(info):
253
+ info = info()
254
+ is_multimodal = bool(getattr(info, "is_multimodal", False))
255
+ model_name = getattr(info, "name", type(model).__name__)
256
+ if not is_multimodal:
257
+ raise ValueError(
258
+ f"Model '{model_name}' is not a multimodal model. "
259
+ "Select a VLM from the multimodal registry."
260
+ )
261
+ if not hasattr(model, "llm"):
262
+ raise RuntimeError("Multimodal scoring requires a VLLMModel with .llm available.")
263
+ llm = model.llm
264
+ tokenizer = llm.get_tokenizer()
265
+
266
+ placeholder = getattr(info, "image_placeholder", None) or "<image>"
267
+ prompt_text = prompt or ""
268
+ candidate_prompts = _build_prompt_candidates(tokenizer, prompt_text, answer, placeholder)
269
+
270
+ from vllm import SamplingParams
271
+
272
+ sp = SamplingParams(
273
+ max_tokens=1,
274
+ temperature=0.0,
275
+ top_p=1.0,
276
+ logprobs=5,
277
+ prompt_logprobs=1,
278
+ )
279
+
280
+ def _generate_with_fallback(full_prompt: str) -> Any:
281
+ mm_key = getattr(info, "mm_data_key", None) or "image"
282
+ attempts = [
283
+ (f"{mm_key}=[image]", {mm_key: [image]}),
284
+ (f"{mm_key}=image", {mm_key: image}),
285
+ ]
286
+ errors: List[str] = []
287
+ last_exc: Exception | None = None
288
+ for label, mm_data in attempts:
289
+ try:
290
+ requests = [{"prompt": full_prompt, "multi_modal_data": mm_data}]
291
+ return llm.generate(requests, sp)
292
+ except Exception as exc:
293
+ last_exc = exc
294
+ errors.append(f"{label}: {type(exc).__name__}: {exc}")
295
+ detail = "; ".join(errors) if errors else "Unknown error"
296
+ raise ValueError(
297
+ "vLLM failed to process the multimodal prompt. "
298
+ f"Attempts: {detail}"
299
+ ) from last_exc
300
+
301
+ last_exc: Exception | None = None
302
+ for base_prompt, full_prompt in candidate_prompts:
303
+ try:
304
+ results = _generate_with_fallback(full_prompt)
305
+ req_out = results[0]
306
+ prompt_logprobs = getattr(req_out, "prompt_logprobs", None)
307
+ return _score_from_prompt_logprobs(tokenizer, base_prompt, full_prompt, prompt_logprobs)
308
+ except Exception as exc:
309
+ last_exc = exc
310
+ continue
311
+
312
+ detail = f"vLLM failed to score the multimodal prompt. Last error: {last_exc}"
313
+ raise ValueError(detail)
314
+
315
+
316
+ def _score_multimodal_vllm_batch(
317
+ model: BaseModel,
318
+ prompts: Sequence[str],
319
+ answer: str,
320
+ images: Sequence[Image.Image],
321
+ ) -> List[float]:
322
+ info = getattr(model, "info", None)
323
+ if callable(info):
324
+ info = info()
325
+ is_multimodal = bool(getattr(info, "is_multimodal", False))
326
+ model_name = getattr(info, "name", type(model).__name__)
327
+ if not is_multimodal:
328
+ raise ValueError(
329
+ f"Model '{model_name}' is not a multimodal model. "
330
+ "Select a VLM from the multimodal registry."
331
+ )
332
+ if not hasattr(model, "llm"):
333
+ raise RuntimeError("Multimodal scoring requires a VLLMModel with .llm available.")
334
+
335
+ if len(prompts) != len(images):
336
+ raise ValueError("prompts and images must have the same length.")
337
+ if not prompts:
338
+ return []
339
+
340
+ llm = model.llm
341
+ tokenizer = llm.get_tokenizer()
342
+ placeholder = getattr(info, "image_placeholder", None) or "<image>"
343
+
344
+ candidate_batches = _build_prompt_candidates_batch(tokenizer, prompts, answer, placeholder)
345
+
346
+ from vllm import SamplingParams
347
+
348
+ sp = SamplingParams(
349
+ max_tokens=1,
350
+ temperature=0.0,
351
+ top_p=1.0,
352
+ logprobs=5,
353
+ prompt_logprobs=1,
354
+ )
355
+
356
+ def _generate_with_fallback_batch(full_prompts: Sequence[str]) -> Any:
357
+ mm_key = getattr(info, "mm_data_key", None) or "image"
358
+ attempts = [
359
+ (f"{mm_key}=[image]", lambda img: {mm_key: [img]}),
360
+ (f"{mm_key}=image", lambda img: {mm_key: img}),
361
+ ]
362
+ errors: List[str] = []
363
+ last_exc: Exception | None = None
364
+ for label, builder in attempts:
365
+ try:
366
+ requests = [
367
+ {"prompt": prompt, "multi_modal_data": builder(image)}
368
+ for prompt, image in zip(full_prompts, images)
369
+ ]
370
+ return llm.generate(requests, sp)
371
+ except Exception as exc:
372
+ last_exc = exc
373
+ errors.append(f"{label}: {type(exc).__name__}: {exc}")
374
+ detail = "; ".join(errors) if errors else "Unknown error"
375
+ raise ValueError(
376
+ "vLLM failed to process the multimodal prompt. "
377
+ f"Attempts: {detail}"
378
+ ) from last_exc
379
+
380
+ last_exc: Exception | None = None
381
+ for base_prompts, full_prompts in candidate_batches:
382
+ try:
383
+ results = _generate_with_fallback_batch(full_prompts)
384
+ scores: List[float] = []
385
+ for base_prompt, full_prompt, req_out in zip(base_prompts, full_prompts, results):
386
+ prompt_logprobs = getattr(req_out, "prompt_logprobs", None)
387
+ stats = _score_from_prompt_logprobs(
388
+ tokenizer,
389
+ base_prompt,
390
+ full_prompt,
391
+ prompt_logprobs,
392
+ )
393
+ avg_logprob = stats.get("avg_logprob")
394
+ if avg_logprob is None or not math.isfinite(avg_logprob):
395
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
396
+ scores.append(float(avg_logprob))
397
+ return scores
398
+ except Exception as exc:
399
+ last_exc = exc
400
+ continue
401
+
402
+ detail = f"vLLM failed to score the multimodal prompt. Last error: {last_exc}"
403
+ raise ValueError(detail)
404
+
405
+
406
+ @dataclass
407
+ class ImageAttributorConfig:
408
+ mask_level: str = "patch"
409
+ grid_size: int | None = None
410
+ method: str = "shapley"
411
+ interaction_order: int = 2
412
+ top_k_interactions: int = 15
413
+ random_seed: int = 0
414
+ mask_mode: str = "blur"
415
+ batch_size: int = 8
416
+
417
+ def __post_init__(self) -> None:
418
+ method = (self.method or "shapley").lower()
419
+ if method not in SUPPORTED_METHODS:
420
+ raise ValueError(f"Unsupported attribution method: {self.method}")
421
+ self.method = method
422
+
423
+
424
+ @dataclass
425
+ class MMAttributorConfig:
426
+ mask_level_image: str = "patch"
427
+ mask_level_text: str = "word"
428
+ grid_size: int | None = None
429
+ method: str = "shapley"
430
+ interaction_order: int = 2
431
+ top_k_interactions: int = 15
432
+ random_seed: int = 0
433
+ mask_mode: str = "blur"
434
+ batch_size: int = 8
435
+
436
+ def __post_init__(self) -> None:
437
+ method = (self.method or "shapley").lower()
438
+ if method not in SUPPORTED_METHODS:
439
+ raise ValueError(f"Unsupported attribution method: {self.method}")
440
+ self.method = method
441
+
442
+
443
+ def _format_interactions(
444
+ interactions: List[Tuple[Tuple[int, ...], float]],
445
+ labels: Sequence[str],
446
+ method: str,
447
+ ) -> List[Dict[str, Any]]:
448
+ payload: List[Dict[str, Any]] = []
449
+ for loc, value in interactions:
450
+ indices = sorted(loc)
451
+ feature_list = [labels[idx] for idx in indices if idx < len(labels)]
452
+ payload.append(
453
+ {
454
+ "indices": indices,
455
+ "features": " × ".join(feature_list),
456
+ "feature_list": feature_list,
457
+ "value": float(value),
458
+ "method": method,
459
+ "order": len(indices),
460
+ }
461
+ )
462
+ return payload
463
+
464
+
465
+ class ImageAttributor:
466
+ def __init__(self, model: BaseModel, config: ImageAttributorConfig | None = None) -> None:
467
+ self.model = model
468
+ self.config = config or ImageAttributorConfig()
469
+
470
+ def _build_set_function(
471
+ self,
472
+ image: Image.Image,
473
+ prompt: str,
474
+ answer: str,
475
+ regions: Sequence[ImageRegion],
476
+ ):
477
+ if not answer:
478
+ raise ValueError("Answer must be non-empty for multimodal scoring.")
479
+ n_features = len(regions)
480
+ cache: Dict[Tuple[int, ...], float] = {}
481
+
482
+ def score_active(active_tuple: Tuple[int, ...]) -> float:
483
+ if active_tuple in cache:
484
+ return cache[active_tuple]
485
+ coalition_mask = [0] * n_features
486
+ for idx in active_tuple:
487
+ if 0 <= idx < n_features:
488
+ coalition_mask[idx] = 1
489
+ masked_image = apply_image_mask(
490
+ image,
491
+ regions,
492
+ coalition_mask,
493
+ mode=self.config.mask_mode,
494
+ )
495
+ score = _score_multimodal_vllm(self.model, prompt, answer, masked_image)
496
+ avg_logprob = score.get("avg_logprob")
497
+ if avg_logprob is None or not math.isfinite(avg_logprob):
498
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
499
+ cache[active_tuple] = float(avg_logprob)
500
+ return cache[active_tuple]
501
+
502
+ def set_function(selection: Sequence[int]) -> float:
503
+ if len(selection) != n_features:
504
+ raise ValueError("Selection length must match number of regions.")
505
+ active = tuple(idx for idx, flag in enumerate(selection) if int(flag))
506
+ return score_active(active)
507
+
508
+ def batch_set_function(selections: Sequence[Sequence[int]]) -> List[float]:
509
+ num_rows = len(selections)
510
+ if num_rows == 0:
511
+ return []
512
+ if len(selections[0]) != n_features:
513
+ raise ValueError("Selection length must match number of regions.")
514
+ results: List[float] = [0.0] * num_rows
515
+ pending: List[Tuple[int, ...]] = []
516
+ pending_indices: List[int] = []
517
+ for row_idx, row in enumerate(selections):
518
+ active = tuple(idx for idx, flag in enumerate(row) if int(flag))
519
+ if active in cache:
520
+ results[row_idx] = cache[active]
521
+ else:
522
+ pending.append(active)
523
+ pending_indices.append(row_idx)
524
+
525
+ batch_size = max(1, int(self.config.batch_size or 1))
526
+ for start in range(0, len(pending), batch_size):
527
+ batch_actives = pending[start : start + batch_size]
528
+ batch_indices = pending_indices[start : start + batch_size]
529
+ masked_images: List[Image.Image] = []
530
+ batch_prompts: List[str] = []
531
+ for active_tuple in batch_actives:
532
+ coalition_mask = [0] * n_features
533
+ for idx in active_tuple:
534
+ if 0 <= idx < n_features:
535
+ coalition_mask[idx] = 1
536
+ masked_images.append(
537
+ apply_image_mask(
538
+ image,
539
+ regions,
540
+ coalition_mask,
541
+ mode=self.config.mask_mode,
542
+ )
543
+ )
544
+ batch_prompts.append(prompt)
545
+ try:
546
+ batch_scores = _score_multimodal_vllm_batch(
547
+ self.model,
548
+ batch_prompts,
549
+ answer,
550
+ masked_images,
551
+ )
552
+ except Exception:
553
+ batch_scores = []
554
+ for batch_prompt, masked_image in zip(batch_prompts, masked_images):
555
+ score = _score_multimodal_vllm(
556
+ self.model,
557
+ batch_prompt,
558
+ answer,
559
+ masked_image,
560
+ )
561
+ avg_logprob = score.get("avg_logprob")
562
+ if avg_logprob is None or not math.isfinite(avg_logprob):
563
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
564
+ batch_scores.append(float(avg_logprob))
565
+
566
+ for row_idx, active_tuple, score in zip(
567
+ batch_indices,
568
+ batch_actives,
569
+ batch_scores,
570
+ ):
571
+ if score is None or not math.isfinite(score):
572
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
573
+ cache[active_tuple] = float(score)
574
+ results[row_idx] = float(score)
575
+
576
+ return results
577
+
578
+ set_function.batch = batch_set_function # type: ignore[attr-defined]
579
+
580
+ return set_function
581
+
582
+ def compute_attributions(self, image_b64: str, prompt: str, answer: str) -> Dict[str, Any]:
583
+ image = _decode_image_b64(image_b64)
584
+ regions = build_regions(image, self.config.mask_level, self.config.grid_size)
585
+ labels = [region.label for region in regions]
586
+
587
+ set_function = self._build_set_function(image, prompt, answer, regions)
588
+ mobius = run_proxyspex(
589
+ set_function,
590
+ labels,
591
+ max_order=self.config.interaction_order,
592
+ seed=self.config.random_seed,
593
+ )
594
+
595
+ if self.config.method == "shapley":
596
+ values = mobius_to_shapley(mobius)
597
+ interactions = shapley_interactions(
598
+ mobius,
599
+ order=self.config.interaction_order,
600
+ top_k=self.config.top_k_interactions,
601
+ )
602
+ else:
603
+ values = mobius_to_banzhaf(mobius)
604
+ interactions = banzhaf_interactions(
605
+ mobius,
606
+ order=self.config.interaction_order,
607
+ top_k=self.config.top_k_interactions,
608
+ )
609
+
610
+ value_list = []
611
+ raw_values = []
612
+ for idx in range(len(regions)):
613
+ value = float(values.get((idx,), 0.0))
614
+ raw_values.append(value)
615
+ value_list.append({"index": idx, "value": value, "method": self.config.method})
616
+
617
+ overlay = render_overlay(image, regions, raw_values)
618
+ image_payload = {
619
+ "image_b64": _encode_image_b64(image),
620
+ "overlay_b64": _encode_image_b64(overlay),
621
+ "width": image.size[0],
622
+ "height": image.size[1],
623
+ "mask_level": self.config.mask_level,
624
+ "grid_size": self.config.grid_size,
625
+ }
626
+
627
+ region_payload = [
628
+ {"index": region.index, "label": region.label, "bbox": list(region.bbox)}
629
+ for region in regions
630
+ ]
631
+
632
+ return {
633
+ "method": self.config.method,
634
+ "regions": region_payload,
635
+ "values": value_list,
636
+ "interactions": _format_interactions(interactions, labels, self.config.method),
637
+ "image": image_payload,
638
+ }
639
+
640
+ def compute_attributions_with_regions(
641
+ self,
642
+ image_b64: str,
643
+ prompt: str,
644
+ answer: str,
645
+ regions: List[ImageRegion],
646
+ ) -> Dict[str, Any]:
647
+ """Like compute_attributions() but accepts pre-built regions (e.g. UnSAM segments).
648
+
649
+ Bypasses build_regions() — all other logic is identical.
650
+ """
651
+ image = _decode_image_b64(image_b64)
652
+ labels = [region.label for region in regions]
653
+
654
+ set_function = self._build_set_function(image, prompt, answer, regions)
655
+ mobius = run_proxyspex(
656
+ set_function,
657
+ labels,
658
+ max_order=self.config.interaction_order,
659
+ seed=self.config.random_seed,
660
+ )
661
+
662
+ if self.config.method == "shapley":
663
+ values = mobius_to_shapley(mobius)
664
+ interactions = shapley_interactions(
665
+ mobius,
666
+ order=self.config.interaction_order,
667
+ top_k=self.config.top_k_interactions,
668
+ )
669
+ else:
670
+ values = mobius_to_banzhaf(mobius)
671
+ interactions = banzhaf_interactions(
672
+ mobius,
673
+ order=self.config.interaction_order,
674
+ top_k=self.config.top_k_interactions,
675
+ )
676
+
677
+ value_list = []
678
+ raw_values = []
679
+ for idx in range(len(regions)):
680
+ value = float(values.get((idx,), 0.0))
681
+ raw_values.append(value)
682
+ value_list.append({"index": idx, "value": value, "method": self.config.method})
683
+
684
+ overlay = render_overlay(image, regions, raw_values)
685
+ image_payload = {
686
+ "image_b64": _encode_image_b64(image),
687
+ "overlay_b64": _encode_image_b64(overlay),
688
+ "width": image.size[0],
689
+ "height": image.size[1],
690
+ "mask_level": "unsam",
691
+ "grid_size": len(regions),
692
+ }
693
+
694
+ region_payload = [
695
+ {"index": region.index, "label": region.label, "bbox": list(region.bbox)}
696
+ for region in regions
697
+ ]
698
+
699
+ return {
700
+ "method": self.config.method,
701
+ "regions": region_payload,
702
+ "values": value_list,
703
+ "interactions": _format_interactions(interactions, labels, self.config.method),
704
+ "image": image_payload,
705
+ }
706
+
707
+
708
+ class MMAttributor:
709
+ def __init__(self, model: BaseModel, config: MMAttributorConfig | None = None) -> None:
710
+ self.model = model
711
+ self.config = config or MMAttributorConfig()
712
+
713
+ def _mask_text(self, text: str) -> Tuple[List[str], MaskingInfo]:
714
+ masker_fn = get_masker(self.config.mask_level_text)
715
+ features, info = mask_text(text or "", masker_fn)
716
+ info.mask_level = self.config.mask_level_text
717
+ return features, info
718
+
719
+ def _build_set_function(
720
+ self,
721
+ image: Image.Image,
722
+ text_features: Sequence[str],
723
+ answer: str,
724
+ regions: Sequence[ImageRegion],
725
+ ):
726
+ if not answer:
727
+ raise ValueError("Answer must be non-empty for multimodal scoring.")
728
+ n_img = len(regions)
729
+ n_txt = len(text_features)
730
+ n_total = n_img + n_txt
731
+ cache: Dict[Tuple[int, ...], float] = {}
732
+
733
+ def score_active(active_tuple: Tuple[int, ...]) -> float:
734
+ if active_tuple in cache:
735
+ return cache[active_tuple]
736
+ mask_img = [0] * n_img
737
+ mask_txt = [0] * n_txt
738
+ for idx in active_tuple:
739
+ if idx < n_img:
740
+ mask_img[idx] = 1
741
+ else:
742
+ mask_txt[idx - n_img] = 1
743
+ masked_image = apply_image_mask(
744
+ image,
745
+ regions,
746
+ mask_img,
747
+ mode=self.config.mask_mode,
748
+ )
749
+ masked_text = " ".join(
750
+ text_features[i] for i, flag in enumerate(mask_txt) if flag
751
+ ).strip()
752
+ score = _score_multimodal_vllm(self.model, masked_text, answer, masked_image)
753
+ avg_logprob = score.get("avg_logprob")
754
+ if avg_logprob is None or not math.isfinite(avg_logprob):
755
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
756
+ cache[active_tuple] = float(avg_logprob)
757
+ return cache[active_tuple]
758
+
759
+ def set_function(selection: Sequence[int]) -> float:
760
+ if len(selection) != n_total:
761
+ raise ValueError("Selection length must match number of features.")
762
+ active = tuple(idx for idx, flag in enumerate(selection) if int(flag))
763
+ return score_active(active)
764
+
765
+ def batch_set_function(selections: Sequence[Sequence[int]]) -> List[float]:
766
+ num_rows = len(selections)
767
+ if num_rows == 0:
768
+ return []
769
+ if len(selections[0]) != n_total:
770
+ raise ValueError("Selection length must match number of features.")
771
+ results: List[float] = [0.0] * num_rows
772
+ pending: List[Tuple[int, ...]] = []
773
+ pending_indices: List[int] = []
774
+ for row_idx, row in enumerate(selections):
775
+ active = tuple(idx for idx, flag in enumerate(row) if int(flag))
776
+ if active in cache:
777
+ results[row_idx] = cache[active]
778
+ else:
779
+ pending.append(active)
780
+ pending_indices.append(row_idx)
781
+
782
+ batch_size = max(1, int(self.config.batch_size or 1))
783
+ for start in range(0, len(pending), batch_size):
784
+ batch_actives = pending[start : start + batch_size]
785
+ batch_indices = pending_indices[start : start + batch_size]
786
+ masked_images: List[Image.Image] = []
787
+ batch_prompts: List[str] = []
788
+ for active_tuple in batch_actives:
789
+ mask_img = [0] * n_img
790
+ mask_txt = [0] * n_txt
791
+ for idx in active_tuple:
792
+ if idx < n_img:
793
+ mask_img[idx] = 1
794
+ else:
795
+ mask_txt[idx - n_img] = 1
796
+ masked_images.append(
797
+ apply_image_mask(
798
+ image,
799
+ regions,
800
+ mask_img,
801
+ mode=self.config.mask_mode,
802
+ )
803
+ )
804
+ masked_text = " ".join(
805
+ text_features[i] for i, flag in enumerate(mask_txt) if flag
806
+ ).strip()
807
+ batch_prompts.append(masked_text)
808
+
809
+ try:
810
+ batch_scores = _score_multimodal_vllm_batch(
811
+ self.model,
812
+ batch_prompts,
813
+ answer,
814
+ masked_images,
815
+ )
816
+ except Exception:
817
+ batch_scores = []
818
+ for batch_prompt, masked_image in zip(batch_prompts, masked_images):
819
+ score = _score_multimodal_vllm(
820
+ self.model,
821
+ batch_prompt,
822
+ answer,
823
+ masked_image,
824
+ )
825
+ avg_logprob = score.get("avg_logprob")
826
+ if avg_logprob is None or not math.isfinite(avg_logprob):
827
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
828
+ batch_scores.append(float(avg_logprob))
829
+
830
+ for row_idx, active_tuple, score in zip(
831
+ batch_indices,
832
+ batch_actives,
833
+ batch_scores,
834
+ ):
835
+ if score is None or not math.isfinite(score):
836
+ raise ValueError("Multimodal scoring returned a non-finite logprob.")
837
+ cache[active_tuple] = float(score)
838
+ results[row_idx] = float(score)
839
+
840
+ return results
841
+
842
+ set_function.batch = batch_set_function # type: ignore[attr-defined]
843
+
844
+ return set_function
845
+
846
+ def compute_attributions(self, image_b64: str, text_context: str, answer: str) -> Dict[str, Any]:
847
+ image = _decode_image_b64(image_b64)
848
+ regions = build_regions(image, self.config.mask_level_image, self.config.grid_size)
849
+ text_features, text_info = self._mask_text(text_context)
850
+ n_img = len(regions)
851
+
852
+ labels = [f"img:{region.label}" for region in regions] + [
853
+ f"txt:{feature}" for feature in text_features
854
+ ]
855
+ set_function = self._build_set_function(image, text_features, answer, regions)
856
+ mobius = run_proxyspex(
857
+ set_function,
858
+ labels,
859
+ max_order=self.config.interaction_order,
860
+ seed=self.config.random_seed,
861
+ )
862
+
863
+ if self.config.method == "shapley":
864
+ values = mobius_to_shapley(mobius)
865
+ interactions = shapley_interactions(
866
+ mobius,
867
+ order=self.config.interaction_order,
868
+ top_k=self.config.top_k_interactions,
869
+ )
870
+ else:
871
+ values = mobius_to_banzhaf(mobius)
872
+ interactions = banzhaf_interactions(
873
+ mobius,
874
+ order=self.config.interaction_order,
875
+ top_k=self.config.top_k_interactions,
876
+ )
877
+
878
+ features_payload = []
879
+ image_values = []
880
+ for idx, label in enumerate(labels):
881
+ value = float(values.get((idx,), 0.0))
882
+ modality = "image" if idx < n_img else "text"
883
+ ref_index = idx if modality == "image" else idx - n_img
884
+ features_payload.append(
885
+ {
886
+ "index": idx,
887
+ "feature": label,
888
+ "value": value,
889
+ "method": self.config.method,
890
+ "modality": modality,
891
+ "ref_index": ref_index,
892
+ }
893
+ )
894
+ if modality == "image":
895
+ image_values.append(value)
896
+
897
+ overlay = render_overlay(image, regions, image_values)
898
+ image_payload = {
899
+ "image_b64": _encode_image_b64(image),
900
+ "overlay_b64": _encode_image_b64(overlay),
901
+ "width": image.size[0],
902
+ "height": image.size[1],
903
+ "mask_level": self.config.mask_level_image,
904
+ "grid_size": self.config.grid_size,
905
+ }
906
+
907
+ region_payload = [
908
+ {"index": region.index, "label": region.label, "bbox": list(region.bbox)}
909
+ for region in regions
910
+ ]
911
+
912
+ return {
913
+ "method": self.config.method,
914
+ "features": features_payload,
915
+ "interactions": _format_interactions(interactions, labels, self.config.method),
916
+ "regions": region_payload,
917
+ "image": image_payload,
918
+ "text": {
919
+ "features": text_features,
920
+ "masking": asdict(text_info),
921
+ },
922
+ }
attribution/proxyspex.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from itertools import combinations
5
+ from typing import Callable, Dict, Optional, Sequence, Tuple
6
+ from sklearn.model_selection import GridSearchCV, KFold
7
+ import pandas as pd
8
+ import lightgbm as lgb
9
+ import numpy as np
10
+ from collections import defaultdict
11
+ from typing import Any
12
+
13
+ from .utils import fourier_to_mobius
14
+
15
+
16
+ def check_lgbm() -> Dict[str, Any]:
17
+ """
18
+ Return LightGBM status information.
19
+
20
+ Since LightGBM is a hard dependency (imported at module level), this
21
+ should always succeed after a successful import of this module.
22
+ """
23
+ return {
24
+ "installed": True,
25
+ "version": lgb.__version__,
26
+ "interaction_mode": "proxyspex",
27
+ }
28
+
29
+
30
+ def _pairwise_mobius_small_n(
31
+ set_function: Callable[[Tuple[int, ...]], float],
32
+ n_features: int,
33
+ ) -> Dict[Tuple[int, ...], float]:
34
+ """
35
+ Exact Mobius up to order 2 (empty, singletons, pairs) for small n_features.
36
+
37
+ m({i}) = f(1_i) - f(0)
38
+ m({i,j}) = f(1_i,1_j) - f(1_i,0_j) - f(0_i,1_j) + f(0,0)
39
+ """
40
+ mobius: Dict[Tuple[int, ...], float] = {}
41
+
42
+ # --- empty set
43
+ empty_selection = tuple(0 for _ in range(n_features))
44
+ f0 = float(set_function(empty_selection))
45
+ mobius[()] = f0
46
+
47
+ # --- singletons
48
+ f_single: Dict[int, float] = {}
49
+ for i in range(n_features):
50
+ mask = [0] * n_features
51
+ mask[i] = 1
52
+ sel = tuple(mask)
53
+ fi = float(set_function(sel))
54
+ f_single[i] = fi
55
+ mobius[(i,)] = fi - f0
56
+
57
+ # --- pairs
58
+ for i, j in combinations(range(n_features), 2):
59
+ mask_ij = [0] * n_features
60
+ mask_ij[i] = 1
61
+ mask_ij[j] = 1
62
+ sel_ij = tuple(mask_ij)
63
+ f_ij = float(set_function(sel_ij))
64
+
65
+ # Mobius coefficient for pair {i,j}
66
+ m_ij = f_ij - f_single[i] - f_single[j] + f0
67
+ mobius[(i, j)] = m_ij
68
+
69
+ return mobius
70
+
71
+
72
+ def run_proxyspex(
73
+ set_function: Callable[[Tuple[int, ...]], float],
74
+ features: Sequence[str],
75
+ max_order: int = 3,
76
+ seed: Optional[int] = None,
77
+ ) -> Dict[Tuple[int, ...], float]:
78
+ """
79
+ Execute ProxySPEX once to obtain Mobius interaction coefficients.
80
+
81
+ For small n_features (<= SMALL_N_THRESHOLD), we bypass ProxySPEX and
82
+ compute Mobius terms exactly up to order 2. This gives reliable singleton
83
+ and pairwise interactions for the web UI.
84
+ """
85
+ # global ProxySPEX, _shapiq_import_error
86
+
87
+ n_features = len(features)
88
+ if n_features == 0:
89
+ return {}
90
+
91
+ # ---- SMALL-N EXACT PATH (no shapiq, no lightgbm, always safe) ----
92
+ # if n_features <= SMALL_N_THRESHOLD:
93
+ # print(f"[run_proxyspex] Using small-n pairwise backend (n={n_features}).")
94
+ # return _pairwise_mobius_small_n(set_function, n_features)
95
+
96
+ # ---- LARGE-N PATH: try ProxySPEX lazily, but never crash ----
97
+
98
+ # Lazy import of ProxySPEX the first time we need it
99
+ # if ProxySPEX is None and _shapiq_import_error is None: # pragma: no cover
100
+ # try:
101
+ # from shapiq import ProxySPEX as _ProxySPEX # type: ignore
102
+ # ProxySPEX = _ProxySPEX
103
+ # logger.info("[run_proxyspex] Loaded ProxySPEX successfully.")
104
+ # except Exception as e:
105
+ # _shapiq_import_error = e
106
+ # ProxySPEX = None
107
+ # logger.warning(
108
+ # "[run_proxyspex] ProxySPEX unavailable (%s); "
109
+ # "falling back to singleton-only Mobius.",
110
+ # e,
111
+ # )
112
+
113
+ # If ProxySPEX is still not available, fall back instead of raising
114
+ # if ProxySPEX is None: # pragma: no cover
115
+ # print("[run_proxyspex] ProxySPEX unavailable; ")
116
+ # print(
117
+ # "[run_proxyspex] Using small-n pairwise backend (n=32). "
118
+ # f"(n={n_features})."
119
+ # )
120
+ # return _singleton_mobius(set_function, n_features)
121
+
122
+ # We have ProxySPEX; run it for real
123
+ order = max(1, min(max_order, n_features))
124
+
125
+ def game(coalitions):
126
+ arr = np.asarray(coalitions)
127
+ if arr.ndim == 1:
128
+ outputs = set_function(tuple(int(x) for x in arr))
129
+ return outputs - np.mean(outputs)
130
+ if arr.ndim == 2:
131
+ if hasattr(set_function, "batch"):
132
+ outputs = np.asarray(set_function.batch(arr), dtype=float)
133
+ else:
134
+ outputs = np.array(
135
+ [set_function(tuple(int(x) for x in row)) for row in arr],
136
+ dtype=float,
137
+ )
138
+ return outputs - np.mean(outputs)
139
+ raise ValueError("Coalitions input must be 1D or 2D.")
140
+
141
+ # try:
142
+ # approximator = ProxySPEX(
143
+ # n=n_features,
144
+ # index="FSII",
145
+ # max_order=n_features,
146
+ # random_state=seed,
147
+ # )
148
+ # except Exception as e:
149
+ # print(e)
150
+ # print(
151
+ # "[run_proxyspex] Could not construct ProxySPEX (%s); "
152
+ # "falling back to singleton-only Mobius." % e
153
+ # )
154
+ # return _singleton_mobius(set_function, n_features)
155
+
156
+ budget = int(10 * n_features * math.log(n_features+1, 2))
157
+
158
+ # try:
159
+ # print(budget)
160
+ # raw_result = approximator.approximate(budget=budget, game=game)
161
+ # except Exception as e:
162
+ # print(
163
+ # f"[run_proxyspex] ProxySPEX failed with {type(e).__name__}: {e}. "
164
+ # "Falling back to singleton-only Mobius."
165
+ # )
166
+ # return _singleton_mobius(set_function, n_features)
167
+ dict_values = proxyspex(n_features, budget, game, max_order=order, random_seed=seed)
168
+
169
+ mobius_dict: Dict[Tuple[int, ...], float] = {}
170
+ for loc, value in dict_values.items():
171
+ normalized = tuple(sorted(loc))
172
+ mobius_dict[normalized] = float(np.real(value))
173
+
174
+ return mobius_dict
175
+
176
+
177
+ def proxyspex(n, budget, game, max_order=None, random_seed=0):
178
+ # Sample budget uniform coalitions (boolean lists) from game
179
+ # create a np array of shape (buget, n) of 0s and 1s
180
+ print("the random seed is: ", random_seed)
181
+ print(f"[proxyspex] n_features={n} budget={budget} max_order={max_order}")
182
+ rng = np.random.default_rng(random_seed)
183
+ X_samples = rng.integers(0, 2, size=(budget, n), dtype=np.int8)
184
+
185
+ train_X = pd.DataFrame(
186
+ X_samples,
187
+ columns=np.array([f"f{i}" for i in range(n)]),
188
+ ).astype(np.int8)
189
+
190
+ y_samples = game(X_samples)
191
+
192
+
193
+ if len(train_X) < 5:
194
+ # fit just one XGBoost with depth 1
195
+ base_model = lgb.LGBMRegressor(verbose=-1, n_jobs=1,
196
+ random_state=random_seed,
197
+ max_depth=1,
198
+ )
199
+ base_model.fit(train_X, y_samples)
200
+ best_model = base_model
201
+ else:
202
+ base_model = lgb.LGBMRegressor(verbose=-1, n_jobs=1,
203
+ random_state=random_seed,
204
+ )
205
+ param_grid = {
206
+ "max_depth": [3, 5],
207
+ "n_estimators": [500, 1000],
208
+ "learning_rate": [0.01, 0.1],
209
+ }
210
+ cv_strategy = KFold(n_splits=5, shuffle=True, random_state=random_seed)
211
+
212
+ grid_search = GridSearchCV(
213
+ estimator=base_model,
214
+ param_grid=param_grid,
215
+ scoring="r2",
216
+ cv=cv_strategy,
217
+ verbose=0,
218
+ n_jobs=1,
219
+ )
220
+
221
+ grid_search.fit(train_X, y_samples)
222
+
223
+ best_model = grid_search.best_estimator_
224
+
225
+ initial_transform = lgboost_to_fourier(best_model.booster_.dump_model())
226
+
227
+ mobius_transform = fourier_to_mobius(initial_transform)
228
+
229
+ max_order = max_order if max_order is not None else n
230
+
231
+ filtered_transform = {k: v for k, v in mobius_transform.items() if len(k) <= max_order}
232
+ return filtered_transform
233
+
234
+
235
+ def lgboost_to_fourier(model_dict: dict[str, Any]) -> dict[tuple[int, ...], float]:
236
+ """Extracts the aggregated Fourier coefficients from an LGBoost model dictionary.
237
+
238
+ This method iterates over all trees in the LightGBM ensemble, computes the
239
+ Fourier coefficients for each individual tree using the `_lgboost_tree_to_fourier`
240
+ helper method, and then sums these coefficients to get the final Fourier
241
+ representation of the complete model.
242
+
243
+ Args:
244
+ model_dict: A dictionary representing the trained LGBoost model, as
245
+ produced by `model.booster_.dump_model()`.
246
+
247
+ Returns:
248
+ A dictionary that maps interaction tuples (representing Fourier frequencies)
249
+ to their aggregated Fourier coefficients.
250
+ """
251
+ aggregated_coeffs = defaultdict(float)
252
+
253
+ for tree_info in model_dict["tree_info"]:
254
+ tree_coeffs = lgboost_tree_to_fourier(tree_info)
255
+ for interaction, value in tree_coeffs.items():
256
+ aggregated_coeffs[interaction] += value
257
+
258
+ # Convert defaultdict to a standard dict, removing zero-valued coefficients
259
+ return {k: v for k, v in aggregated_coeffs.items() if v != 0.0}
260
+
261
+ def lgboost_tree_to_fourier(tree_info: dict[str, Any]) -> dict[tuple[int, ...], float]:
262
+ """Recursively strips the Fourier coefficients from a single LGBoost tree.
263
+
264
+ This method traverses a tree's structure, as provided by LightGBM's `dump_model`
265
+ method, and computes the Fourier representation of the piecewise-constant
266
+ function that the tree defines. The logic is adapted from the work by Gorji et al. (2024).
267
+
268
+ Args:
269
+ tree_info: A dictionary representing a single decision tree from an LGBM model.
270
+
271
+ Returns:
272
+ A dictionary mapping interaction tuples to their corresponding coefficients for
273
+ the single tree.
274
+
275
+ References:
276
+ Gorji, Ali, Andisheh Amrollahi, and Andreas Krause.
277
+ "SHAP values via sparse Fourier representation"
278
+ arXiv preprint arXiv:2410.06300 (2024).
279
+ """
280
+
281
+ def combine_coeffs(
282
+ left_coeffs: dict[tuple[int, ...], float],
283
+ right_coeffs: dict[tuple[int, ...], float],
284
+ feature_idx: int,
285
+ ) -> dict[tuple[int, ...], float]:
286
+ """Combines Fourier coefficients from the left and right children of a split node."""
287
+ combined_coeffs = {}
288
+ all_interactions = set(left_coeffs.keys()) | set(right_coeffs.keys())
289
+
290
+ for interaction in all_interactions:
291
+ left_val = left_coeffs.get(interaction, 0.0)
292
+ right_val = right_coeffs.get(interaction, 0.0)
293
+ combined_coeffs[interaction] = (left_val + right_val) / 2
294
+
295
+ new_interaction = tuple(sorted(set(interaction) | {feature_idx}))
296
+ combined_coeffs[new_interaction] = (left_val - right_val) / 2
297
+ return combined_coeffs
298
+
299
+ def dfs_traverse(node: dict[str, Any]) -> dict[tuple[int, ...], float]:
300
+ """Performs a depth-first traversal of the tree to compute coefficients."""
301
+ # Base case: if the node is a leaf, its function is a constant.
302
+ if "leaf_value" in node:
303
+ # The only non-zero coefficient is for the empty interaction (the bias term).
304
+ return {(): node["leaf_value"]}
305
+ # Recursive step: if the node is a split node.
306
+ left_coeffs = dfs_traverse(node["left_child"])
307
+ right_coeffs = dfs_traverse(node["right_child"])
308
+ feature_idx = node["split_feature"]
309
+ return combine_coeffs(left_coeffs, right_coeffs, feature_idx)
310
+
311
+ return dfs_traverse(tree_info["tree_structure"])
attribution/set_mm.py ADDED
@@ -0,0 +1,2397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ set_mm.py – Cross-modal image×text attribution via mask-and-score.
3
+
4
+ Extension of set.py that treats BOTH image regions AND caption tokens
5
+ as players in the cooperative game, enabling cross-modal interactions.
6
+
7
+ Players:
8
+ - Image regions: seg_0 … seg_{n-1} (from SAM or patch grid)
9
+ - Caption tokens: tok_0 … tok_{m-1} (from CLIP tokenizer)
10
+
11
+ f(S) = CLIP_similarity(masked_image_keeping_S∩image, masked_caption_keeping_S∩text)
12
+
13
+ This enables computing:
14
+ - Image-only Shapley values: seg_i contribution
15
+ - Text-only Shapley values: tok_j contribution
16
+ - Cross-modal interactions: seg_i × tok_j (image region × text token)
17
+
18
+ Usage:
19
+ python set_mm.py # default: UnSAM + all levels + caption tokens
20
+ python set_mm.py --mode patch --grid 4 # 4×4 grid
21
+ python set_mm.py --mode sam --max_regions 16 # SlimSAM segmentation
22
+ python set_mm.py --mode unsam --segment_level fine --max_regions 24
23
+ python set_mm.py --num_images 3 --method banzhaf # 3 images, Banzhaf values
24
+ python set_mm.py --mask_style gray # gray infill for image
25
+ python set_mm.py --mask_style lama # LaMa inpainting
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import csv
32
+ import io
33
+ import math
34
+ import os
35
+ import re
36
+ import sys
37
+ import time
38
+ import warnings
39
+ from collections import defaultdict
40
+ from dataclasses import dataclass, field
41
+ from itertools import combinations
42
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
43
+
44
+ import numpy as np
45
+ from PIL import Image, ImageFilter
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Lazy / optional imports
49
+ # ---------------------------------------------------------------------------
50
+ try:
51
+ import torch
52
+ except ImportError:
53
+ torch = None
54
+
55
+ try:
56
+ from transformers import pipeline as hf_pipeline
57
+ from transformers import SamModel, SamProcessor, CLIPModel, AutoProcessor, CLIPTokenizerFast
58
+ from transformers.image_utils import load_image as hf_load_image
59
+ except ImportError:
60
+ hf_pipeline = None
61
+ SamModel = SamProcessor = CLIPModel = AutoProcessor = CLIPTokenizerFast = None
62
+ hf_load_image = None
63
+
64
+ try:
65
+ from datasets import load_dataset
66
+ except ImportError:
67
+ load_dataset = None
68
+
69
+ try:
70
+ import lightgbm as lgb
71
+ from sklearn.model_selection import GridSearchCV, KFold
72
+ except ImportError:
73
+ lgb = None
74
+ GridSearchCV = KFold = None
75
+
76
+ try:
77
+ import pandas as pd
78
+ except ImportError:
79
+ pd = None
80
+
81
+ try:
82
+ from diffusers import (
83
+ AutoPipelineForInpainting,
84
+ LCMScheduler,
85
+ StableDiffusionInpaintPipeline,
86
+ )
87
+ except ImportError:
88
+ AutoPipelineForInpainting = None
89
+ LCMScheduler = None
90
+ StableDiffusionInpaintPipeline = None
91
+
92
+ try:
93
+ from diffusers import FluxImg2ImgPipeline
94
+ except ImportError:
95
+ FluxImg2ImgPipeline = None
96
+
97
+ try:
98
+ from simple_lama_inpainting import SimpleLama
99
+ except ImportError:
100
+ SimpleLama = None
101
+
102
+ try:
103
+ import cv2 as cv2_mod
104
+ except ImportError:
105
+ cv2_mod = None
106
+
107
+
108
+ # ═══════════════════════════════════════════════════════════════════════════
109
+ # 0. Configuration
110
+ # ═══════════════════════════════════════════════════════════════════════════
111
+
112
+ @dataclass
113
+ class PipelineConfig:
114
+ """Central knob-box for the pipeline."""
115
+
116
+ # ---- dataset ----
117
+ coco_split: str = "train"
118
+ num_images: int = 1
119
+
120
+ # ---- featurisation (step 1) ----
121
+ mode: str = "unsam" # "unsam" | "sam" | "patch"
122
+ sam_model_name: str = "yujunwei04/unsam-whole-image-segmentation"
123
+ sam_points_per_batch: int = 64
124
+ segment_level: str = "all" # "all" | "coarse" | "medium" | "fine"
125
+ grid_size: int = 8
126
+ max_regions: int = 32
127
+
128
+ # ---- masking (steps 2-3) ----
129
+ mask_style: str = "blur"
130
+ blur_radius: int = 12
131
+ diffusion_model_name: str = "runwayml/stable-diffusion-inpainting"
132
+ diffusion_prompt: str = ""
133
+ diffusion_steps: int = 20
134
+ diffusion_local_crop: bool = True
135
+ diffusion_crop_pad: int = 48
136
+ diffusion_crop_min_side: int = 256
137
+ diffusion_local_max_cover: float = 0.7
138
+
139
+ # ---- LCM-LoRA + SDXL inpainting (fast) ----
140
+ lcm_base_model_name: str = "diffusers/stable-diffusion-xl-1.0-inpainting-0.1"
141
+ lcm_lora_id: str = "latent-consistency/lcm-lora-sdxl"
142
+ lcm_steps: int = 4
143
+ lcm_guidance_scale: float = 1.0
144
+
145
+ # ---- FLUX.1-Schnell img2img (fast) ----
146
+ flux_model_name: str = "black-forest-labs/FLUX.1-schnell"
147
+ flux_steps: int = 4
148
+ flux_guidance_scale: float = 0.0
149
+ flux_strength: float = 0.85
150
+ flux_max_sequence_length: int = 256
151
+
152
+ # ---- scoring / f(s) (step 4) ----
153
+ clip_model_name: str = "openai/clip-vit-base-patch32"
154
+
155
+ # ---- cross-modal text masking ----
156
+ text_mask_token: str = "" # replace masked tokens with this (empty = UNK token)
157
+ max_tokens: int = 20 # cap caption tokens for tractability
158
+
159
+ # ---- ProxySPEX attribution ----
160
+ method: str = "shapley"
161
+ max_order: int = 2
162
+ top_k_interactions: int = 15
163
+ random_seed: int = 24
164
+ # random_seed: int = 42
165
+
166
+ # ---- runtime ----
167
+ device: str = "cuda" if (torch is not None and torch.cuda.is_available()) else "cpu"
168
+ record_mask_timing: bool = False
169
+ print_fourier_mobius: bool = False
170
+ print_coeff_top_k: int = 20
171
+
172
+
173
+ @dataclass
174
+ class MaskTimingRecorder:
175
+ """Collect per-mask timing and phase-level aggregates."""
176
+ cocoid: int
177
+ mask_style: str
178
+ out_dir: str
179
+ detail_rows: List[Dict[str, Any]] = field(default_factory=list)
180
+ phase_stats: Dict[str, Dict[str, float]] = field(default_factory=dict)
181
+
182
+ def add(
183
+ self,
184
+ *,
185
+ phase: str,
186
+ event: str,
187
+ total_s: float,
188
+ infill_s: float,
189
+ n_regions: int,
190
+ n_masked_regions: int,
191
+ masked_pixels: int,
192
+ image_hw: Tuple[int, int],
193
+ store_detail: bool = False,
194
+ ) -> None:
195
+ phase = phase or "unknown"
196
+ event = event or ""
197
+ height, width = image_hw
198
+ total_pixels = max(1, int(height) * int(width))
199
+ masked_ratio = float(masked_pixels) / float(total_pixels)
200
+
201
+ st = self.phase_stats.setdefault(
202
+ phase,
203
+ {
204
+ "count": 0.0,
205
+ "total_s_sum": 0.0,
206
+ "infill_s_sum": 0.0,
207
+ "total_s_min": float("inf"),
208
+ "total_s_max": 0.0,
209
+ "infill_s_min": float("inf"),
210
+ "infill_s_max": 0.0,
211
+ "masked_ratio_sum": 0.0,
212
+ },
213
+ )
214
+ st["count"] += 1.0
215
+ st["total_s_sum"] += float(total_s)
216
+ st["infill_s_sum"] += float(infill_s)
217
+ st["total_s_min"] = min(st["total_s_min"], float(total_s))
218
+ st["total_s_max"] = max(st["total_s_max"], float(total_s))
219
+ st["infill_s_min"] = min(st["infill_s_min"], float(infill_s))
220
+ st["infill_s_max"] = max(st["infill_s_max"], float(infill_s))
221
+ st["masked_ratio_sum"] += masked_ratio
222
+
223
+ if store_detail:
224
+ self.detail_rows.append(
225
+ {
226
+ "cocoid": self.cocoid,
227
+ "mask_style": self.mask_style,
228
+ "phase": phase,
229
+ "event": event,
230
+ "n_regions": int(n_regions),
231
+ "n_masked_regions": int(n_masked_regions),
232
+ "masked_pixels": int(masked_pixels),
233
+ "masked_ratio": masked_ratio,
234
+ "total_ms": float(total_s) * 1000.0,
235
+ "infill_ms": float(infill_s) * 1000.0,
236
+ "non_infill_ms": max(float(total_s - infill_s), 0.0) * 1000.0,
237
+ }
238
+ )
239
+
240
+ def write_csvs(self) -> Tuple[str, str]:
241
+ os.makedirs(self.out_dir, exist_ok=True)
242
+ detail_path = os.path.join(
243
+ self.out_dir, f"coco_{self.cocoid}_mask_timing_details.csv"
244
+ )
245
+ summary_path = os.path.join(
246
+ self.out_dir, f"coco_{self.cocoid}_mask_timing_summary.csv"
247
+ )
248
+
249
+ detail_cols = [
250
+ "cocoid",
251
+ "mask_style",
252
+ "phase",
253
+ "event",
254
+ "n_regions",
255
+ "n_masked_regions",
256
+ "masked_pixels",
257
+ "masked_ratio",
258
+ "total_ms",
259
+ "infill_ms",
260
+ "non_infill_ms",
261
+ ]
262
+ with open(detail_path, "w", newline="") as f:
263
+ w = csv.DictWriter(f, fieldnames=detail_cols)
264
+ w.writeheader()
265
+ for row in self.detail_rows:
266
+ w.writerow(row)
267
+
268
+ summary_cols = [
269
+ "cocoid",
270
+ "mask_style",
271
+ "phase",
272
+ "calls",
273
+ "avg_total_ms",
274
+ "min_total_ms",
275
+ "max_total_ms",
276
+ "avg_infill_ms",
277
+ "min_infill_ms",
278
+ "max_infill_ms",
279
+ "avg_non_infill_ms",
280
+ "avg_masked_ratio",
281
+ ]
282
+ with open(summary_path, "w", newline="") as f:
283
+ w = csv.DictWriter(f, fieldnames=summary_cols)
284
+ w.writeheader()
285
+ for phase in sorted(self.phase_stats.keys()):
286
+ st = self.phase_stats[phase]
287
+ cnt = max(1.0, st["count"])
288
+ avg_total = st["total_s_sum"] / cnt
289
+ avg_infill = st["infill_s_sum"] / cnt
290
+ w.writerow(
291
+ {
292
+ "cocoid": self.cocoid,
293
+ "mask_style": self.mask_style,
294
+ "phase": phase,
295
+ "calls": int(st["count"]),
296
+ "avg_total_ms": avg_total * 1000.0,
297
+ "min_total_ms": st["total_s_min"] * 1000.0,
298
+ "max_total_ms": st["total_s_max"] * 1000.0,
299
+ "avg_infill_ms": avg_infill * 1000.0,
300
+ "min_infill_ms": st["infill_s_min"] * 1000.0,
301
+ "max_infill_ms": st["infill_s_max"] * 1000.0,
302
+ "avg_non_infill_ms": max(avg_total - avg_infill, 0.0) * 1000.0,
303
+ "avg_masked_ratio": st["masked_ratio_sum"] / cnt,
304
+ }
305
+ )
306
+ return detail_path, summary_path
307
+
308
+
309
+ # ═══════════════════════════════════════════════════════════════════════════
310
+ # 1. Image featurisation (reused from set.py)
311
+ # ═══════════════════════════════════════════════════════════════════════════
312
+
313
+ @dataclass(frozen=True)
314
+ class ImageRegion:
315
+ """One region of an image."""
316
+ index: int
317
+ bbox: Tuple[int, int, int, int]
318
+ label: str
319
+ mask: Optional[np.ndarray] = field(default=None, repr=False)
320
+
321
+
322
+ def _load_sam_pipeline(cfg: PipelineConfig):
323
+ if hf_pipeline is None:
324
+ raise ImportError("transformers is required")
325
+ model_name = (cfg.sam_model_name or "").lower()
326
+ use_image_seg_task = cfg.mode == "unsam" or "unsam" in model_name
327
+ task = "image-segmentation" if use_image_seg_task else "mask-generation"
328
+ kwargs: Dict[str, Any] = {
329
+ "model": cfg.sam_model_name,
330
+ "device": cfg.device,
331
+ }
332
+ if task == "mask-generation":
333
+ kwargs["points_per_batch"] = cfg.sam_points_per_batch
334
+ return hf_pipeline(task, **kwargs)
335
+
336
+
337
+ def _normalize_bool_mask(raw_mask: Any, image_size: Tuple[int, int]) -> np.ndarray:
338
+ width, height = image_size
339
+ if isinstance(raw_mask, Image.Image):
340
+ arr = np.asarray(raw_mask)
341
+ elif torch is not None and isinstance(raw_mask, torch.Tensor):
342
+ arr = raw_mask.detach().cpu().numpy()
343
+ else:
344
+ arr = np.asarray(raw_mask)
345
+
346
+ if arr.ndim >= 3:
347
+ arr = arr[..., 0]
348
+ if arr.ndim != 2:
349
+ raise ValueError(f"Unsupported mask shape: {arr.shape}")
350
+
351
+ if np.issubdtype(arr.dtype, np.floating):
352
+ mask = arr > 0.5
353
+ else:
354
+ mask = arr > 0
355
+
356
+ if mask.shape != (height, width):
357
+ resized = Image.fromarray(mask.astype(np.uint8) * 255, mode="L").resize((width, height), Image.NEAREST)
358
+ mask = np.asarray(resized) > 0
359
+ return mask
360
+
361
+
362
+ def _extract_masks_and_scores(outputs: Any, image_size: Tuple[int, int]) -> List[Tuple[np.ndarray, float, int]]:
363
+ records: List[Tuple[np.ndarray, float, int]] = []
364
+
365
+ if isinstance(outputs, dict) and "masks" in outputs:
366
+ masks = outputs.get("masks") or []
367
+ scores = outputs.get("scores") or [1.0] * len(masks)
368
+ for i, raw_mask in enumerate(masks):
369
+ try:
370
+ mask = _normalize_bool_mask(raw_mask, image_size)
371
+ except Exception:
372
+ continue
373
+ if not mask.any():
374
+ continue
375
+ score = float(scores[i]) if i < len(scores) else 1.0
376
+ area = int(mask.sum())
377
+ records.append((mask, score, area))
378
+ return records
379
+
380
+ if isinstance(outputs, list):
381
+ for item in outputs:
382
+ if not isinstance(item, dict):
383
+ continue
384
+ raw_mask = item.get("mask")
385
+ if raw_mask is None:
386
+ raw_mask = item.get("segmentation")
387
+ if raw_mask is None:
388
+ continue
389
+ try:
390
+ mask = _normalize_bool_mask(raw_mask, image_size)
391
+ except Exception:
392
+ continue
393
+ if not mask.any():
394
+ continue
395
+ raw_score = (
396
+ item.get("score")
397
+ or item.get("confidence")
398
+ or item.get("stability_score")
399
+ or item.get("predicted_iou")
400
+ or 1.0
401
+ )
402
+ score = float(raw_score)
403
+ area = int(mask.sum())
404
+ records.append((mask, score, area))
405
+ return records
406
+
407
+ raise ValueError(f"Unsupported segmentation output format: {type(outputs)}")
408
+
409
+
410
+ def _select_masks_by_level(
411
+ records: List[Tuple[np.ndarray, float, int]],
412
+ level: str,
413
+ max_regions: int,
414
+ ) -> List[Tuple[np.ndarray, float, int]]:
415
+ if not records:
416
+ return []
417
+
418
+ level = (level or "all").lower()
419
+ by_score = sorted(records, key=lambda row: row[1], reverse=True)
420
+ if level == "all":
421
+ return by_score[:max_regions]
422
+
423
+ by_area_desc = sorted(records, key=lambda row: row[2], reverse=True)
424
+ n = len(by_area_desc)
425
+ if n < 3:
426
+ return by_score[:max_regions]
427
+
428
+ cut1 = max(1, n // 3)
429
+ cut2 = max(cut1 + 1, (2 * n) // 3)
430
+ buckets: Dict[str, List[Tuple[np.ndarray, float, int]]] = {
431
+ "coarse": by_area_desc[:cut1],
432
+ "medium": by_area_desc[cut1:cut2],
433
+ "fine": by_area_desc[cut2:],
434
+ }
435
+ chosen = buckets.get(level) or by_area_desc
436
+ chosen_by_score = sorted(chosen, key=lambda row: row[1], reverse=True)
437
+ return chosen_by_score[:max_regions]
438
+
439
+
440
+ def featurise_sam(image: Image.Image, cfg: PipelineConfig, sam_pipe=None) -> List[ImageRegion]:
441
+ if sam_pipe is None:
442
+ sam_pipe = _load_sam_pipeline(cfg)
443
+
444
+ if cfg.mode == "sam":
445
+ outputs = sam_pipe(image, points_per_batch=cfg.sam_points_per_batch)
446
+ else:
447
+ outputs = sam_pipe(image)
448
+
449
+ raw_records = _extract_masks_and_scores(outputs, image.size)
450
+ picked = _select_masks_by_level(raw_records, cfg.segment_level, cfg.max_regions)
451
+ regions = []
452
+ for rank, (m, _, _) in enumerate(picked):
453
+ regions.append(ImageRegion(rank, _mask_to_bbox(m), f"seg_{rank}", m))
454
+ seg_tag = "UnSAM" if cfg.mode == "unsam" else "SAM"
455
+ print(
456
+ f" [{seg_tag}] {len(regions)} regions selected (level={cfg.segment_level}, raw={len(raw_records)})"
457
+ )
458
+ return regions
459
+
460
+
461
+ def featurise_patch(image: Image.Image, cfg: PipelineConfig) -> List[ImageRegion]:
462
+ w, h = image.size
463
+ g = cfg.grid_size
464
+ xe = _linspace_edges(w, g)
465
+ ye = _linspace_edges(h, g)
466
+ regions = []
467
+ idx = 0
468
+ for r in range(len(ye) - 1):
469
+ for c in range(len(xe) - 1):
470
+ regions.append(ImageRegion(idx, (xe[c], ye[r], xe[c+1], ye[r+1]), f"patch_{r}_{c}"))
471
+ idx += 1
472
+ print(f" [Patch] {len(regions)} regions ({g}×{g} grid)")
473
+ return regions
474
+
475
+
476
+ def featurise(image, cfg, sam_pipe=None):
477
+ if cfg.mode in ("sam", "unsam"):
478
+ return featurise_sam(image, cfg, sam_pipe=sam_pipe)
479
+ elif cfg.mode == "patch":
480
+ return featurise_patch(image, cfg)
481
+ else:
482
+ raise ValueError(f"Unknown mode: {cfg.mode}")
483
+
484
+
485
+ # ═══════════════════════════════════════════════════════════════════════════
486
+ # 2. Caption tokenisation – caption → list of TokenPlayer
487
+ # ═══════════════════════════════════════════════════════════════════════════
488
+
489
+ @dataclass(frozen=True)
490
+ class TokenPlayer:
491
+ """One caption token treated as a player in the game."""
492
+ index: int # global player index (offset by n_image_regions)
493
+ token_pos: int # position in the token sequence (1-based, skipping BOS)
494
+ token_id: int # vocabulary ID
495
+ token_str: str # decoded string
496
+ label: str # display label, e.g. "tok:dog"
497
+
498
+
499
+ def tokenise_caption(
500
+ caption: str,
501
+ processor: "AutoProcessor",
502
+ cfg: PipelineConfig,
503
+ offset: int = 0,
504
+ ) -> Tuple[List[TokenPlayer], List[int]]:
505
+ """
506
+ Tokenise caption and return a list of TokenPlayers (one per content token).
507
+
508
+ Args:
509
+ caption: The caption string.
510
+ processor: CLIP processor (has .tokenizer).
511
+ cfg: Config with max_tokens.
512
+ offset: Global player index offset (= number of image regions).
513
+
514
+ Returns:
515
+ (players, full_token_ids)
516
+ - players: List[TokenPlayer] for content tokens (excluding BOS/EOS)
517
+ - full_token_ids: The complete token ID list including BOS/EOS
518
+ """
519
+ tokenizer = processor.tokenizer
520
+ encoding = tokenizer(caption, add_special_tokens=True, return_tensors="pt")
521
+ token_ids = encoding["input_ids"][0].tolist() # includes BOS and EOS
522
+
523
+ # Decode each token individually
524
+ players: List[TokenPlayer] = []
525
+ # token_ids[0] = BOS, token_ids[-1] = EOS — skip both
526
+ content_ids = token_ids[1:-1] # just content tokens
527
+ if len(content_ids) > cfg.max_tokens:
528
+ content_ids = content_ids[:cfg.max_tokens]
529
+
530
+ for i, tid in enumerate(content_ids):
531
+ decoded = tokenizer.decode([tid]).strip()
532
+ # Clean up subword artifacts
533
+ if not decoded:
534
+ decoded = f"[{tid}]"
535
+ lbl = f"tok:{decoded}"
536
+ players.append(TokenPlayer(
537
+ index=offset + i,
538
+ token_pos=i + 1, # 1-based position (after BOS)
539
+ token_id=tid,
540
+ token_str=decoded,
541
+ label=lbl,
542
+ ))
543
+
544
+ # Build full_token_ids = BOS + content (possibly truncated) + EOS
545
+ full_ids = [token_ids[0]] + content_ids + [token_ids[-1]]
546
+
547
+ print(f" [Tokens] {len(players)} caption tokens: {[p.token_str for p in players]}")
548
+ return players, full_ids
549
+
550
+
551
+ # ═══════════════════════════════════════════════════════════════════════════
552
+ # 3. Image masking (same as set.py)
553
+ # ════════════════════════════════��══════════════════════════════════════════
554
+
555
+ _diffusion_pipe_cache: Dict[str, Any] = {}
556
+ _PRINT_FOURIER_MOBIUS = False
557
+ _PRINT_COEF_TOP_K = 20
558
+ _lama_pipe_cache: Dict[str, Any] = {}
559
+ _lcm_pipe_cache: Dict[str, Any] = {}
560
+ _flux_schnell_pipe_cache: Dict[str, Any] = {}
561
+
562
+
563
+ def _get_diffusion_pipe(model_name: str, device: str):
564
+ if model_name in _diffusion_pipe_cache:
565
+ return _diffusion_pipe_cache[model_name]
566
+ if StableDiffusionInpaintPipeline is None:
567
+ raise ImportError("diffusers is required for diffusion inpainting")
568
+ if torch is None:
569
+ raise ImportError("torch is required for diffusion inpainting")
570
+ pipe = StableDiffusionInpaintPipeline.from_pretrained(
571
+ model_name, torch_dtype=torch.float16 if device != "cpu" else torch.float32,
572
+ ).to(device)
573
+ pipe.safety_checker = None
574
+ _diffusion_pipe_cache[model_name] = pipe
575
+ return pipe
576
+
577
+
578
+ def _get_lama_pipe(device: str):
579
+ cache_key = device or "cpu"
580
+ if cache_key in _lama_pipe_cache:
581
+ return _lama_pipe_cache[cache_key]
582
+ if SimpleLama is None:
583
+ raise ImportError(
584
+ "simple-lama-inpainting is required for LaMa inpainting. "
585
+ "Install with: pip install simple-lama-inpainting"
586
+ )
587
+ try:
588
+ pipe = SimpleLama(device=device)
589
+ except TypeError:
590
+ pipe = SimpleLama()
591
+ _lama_pipe_cache[cache_key] = pipe
592
+ return pipe
593
+
594
+
595
+ def _select_dtype(device: str):
596
+ if torch is None:
597
+ return None
598
+ if device == "cpu":
599
+ return torch.float32
600
+ if hasattr(torch, "bfloat16"):
601
+ return torch.bfloat16
602
+ return torch.float16
603
+
604
+
605
+ def _get_lcm_pipe(base_model_name: str, lcm_lora_id: str, device: str):
606
+ cache_key = f"{base_model_name}::{lcm_lora_id}::{device}"
607
+ if cache_key in _lcm_pipe_cache:
608
+ return _lcm_pipe_cache[cache_key]
609
+ if AutoPipelineForInpainting is None:
610
+ raise ImportError("diffusers AutoPipelineForInpainting is required for LCM-LoRA SDXL")
611
+ if LCMScheduler is None:
612
+ raise ImportError("LCMScheduler is required for LCM-LoRA SDXL")
613
+ if torch is None:
614
+ raise ImportError("torch is required for LCM-LoRA SDXL")
615
+
616
+ pipe = AutoPipelineForInpainting.from_pretrained(
617
+ base_model_name,
618
+ torch_dtype=_select_dtype(device),
619
+ ).to(device)
620
+
621
+ if hasattr(pipe, "safety_checker"):
622
+ pipe.safety_checker = None
623
+
624
+ # LCM-LoRA can be stacked on top of SDXL inpaint without re-distillation.
625
+ pipe.load_lora_weights(lcm_lora_id)
626
+ pipe.fuse_lora()
627
+ pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config)
628
+
629
+ _lcm_pipe_cache[cache_key] = pipe
630
+ return pipe
631
+
632
+
633
+ def _lcm_inpaint(image, binary_mask, cfg):
634
+ pipe = _get_lcm_pipe(cfg.lcm_base_model_name, cfg.lcm_lora_id, cfg.device)
635
+ prompt = cfg.diffusion_prompt or "high quality photo, realistic"
636
+
637
+ if not binary_mask.any():
638
+ return image
639
+
640
+ use_local_crop = bool(getattr(cfg, "diffusion_local_crop", True))
641
+ local_max_cover = float(getattr(cfg, "diffusion_local_max_cover", 0.7))
642
+ local_max_cover = min(1.0, max(0.0, local_max_cover))
643
+ mask_cover = float(binary_mask.mean())
644
+
645
+ lcm_steps = int(getattr(cfg, "lcm_steps", 4))
646
+ lcm_guidance = float(getattr(cfg, "lcm_guidance_scale", 1.0))
647
+
648
+ if (not use_local_crop) or mask_cover >= local_max_cover:
649
+ orig_size = image.size
650
+ img_r = image.resize((1024, 1024), Image.LANCZOS)
651
+ mask_pil = Image.fromarray((binary_mask.astype(np.uint8) * 255), mode="L").resize((1024, 1024), Image.NEAREST)
652
+ with torch.inference_mode():
653
+ result = pipe(
654
+ prompt=prompt,
655
+ image=img_r,
656
+ mask_image=mask_pil,
657
+ num_inference_steps=lcm_steps,
658
+ guidance_scale=lcm_guidance,
659
+ ).images[0]
660
+ return result.resize(orig_size, Image.LANCZOS)
661
+
662
+ h, w = binary_mask.shape
663
+ x0, y0, x1, y1 = _mask_to_bbox(binary_mask)
664
+ crop_pad = max(0, int(getattr(cfg, "diffusion_crop_pad", 48)))
665
+ x0, y0 = max(0, x0 - crop_pad), max(0, y0 - crop_pad)
666
+ x1, y1 = min(w, x1 + crop_pad), min(h, y1 + crop_pad)
667
+ if x1 <= x0 or y1 <= y0:
668
+ return image
669
+
670
+ crop_img = image.crop((x0, y0, x1, y1))
671
+ crop_mask = binary_mask[y0:y1, x0:x1]
672
+ if not crop_mask.any():
673
+ return image
674
+
675
+ rw, rh = crop_img.size
676
+ min_side = max(8, int(getattr(cfg, "diffusion_crop_min_side", 256)))
677
+ scale = 1.0
678
+ short_side = max(1, min(rw, rh))
679
+ if short_side < min_side:
680
+ scale = float(min_side) / float(short_side)
681
+
682
+ tw = max(8, int(round((rw * scale) / 8.0) * 8))
683
+ th = max(8, int(round((rh * scale) / 8.0) * 8))
684
+ img_r = crop_img.resize((tw, th), Image.LANCZOS)
685
+ mask_pil = Image.fromarray((crop_mask.astype(np.uint8) * 255), mode="L").resize((tw, th), Image.NEAREST)
686
+
687
+ with torch.inference_mode():
688
+ result = pipe(
689
+ prompt=prompt,
690
+ image=img_r,
691
+ mask_image=mask_pil,
692
+ num_inference_steps=lcm_steps,
693
+ guidance_scale=lcm_guidance,
694
+ ).images[0]
695
+
696
+ result_crop = result.resize((rw, rh), Image.LANCZOS)
697
+ out = np.asarray(image).copy()
698
+ roi = out[y0:y1, x0:x1]
699
+ result_arr = np.asarray(result_crop)
700
+
701
+ mh, mw = crop_mask.shape
702
+ rh2, rw2 = result_arr.shape[:2]
703
+ if (rh2, rw2) != (mh, mw):
704
+ result_arr = np.asarray(result_crop.resize((mw, mh), Image.LANCZOS))
705
+ rh2, rw2 = result_arr.shape[:2]
706
+ if roi.shape[:2] != (mh, mw) or (rh2, rw2) != (mh, mw):
707
+ hh = min(roi.shape[0], mh, rh2)
708
+ ww = min(roi.shape[1], mw, rw2)
709
+ roi = roi[:hh, :ww]
710
+ result_arr = result_arr[:hh, :ww]
711
+ crop_mask = crop_mask[:hh, :ww]
712
+
713
+ roi[crop_mask] = result_arr[crop_mask]
714
+ out[y0:y1, x0:x1] = roi
715
+ return Image.fromarray(out)
716
+
717
+
718
+ def _get_flux_schnell_pipe(model_name: str, device: str):
719
+ cache_key = f"{model_name}::{device}"
720
+ if cache_key in _flux_schnell_pipe_cache:
721
+ return _flux_schnell_pipe_cache[cache_key]
722
+ if FluxImg2ImgPipeline is None:
723
+ raise ImportError("diffusers FluxImg2ImgPipeline is required for FLUX.1-Schnell")
724
+ if torch is None:
725
+ raise ImportError("torch is required for FLUX.1-Schnell")
726
+
727
+ pipe = FluxImg2ImgPipeline.from_pretrained(
728
+ model_name,
729
+ torch_dtype=_select_dtype(device),
730
+ ).to(device)
731
+ _flux_schnell_pipe_cache[cache_key] = pipe
732
+ return pipe
733
+
734
+
735
+ def _run_flux_schnell(pipe, image: Image.Image, cfg):
736
+ prompt = cfg.diffusion_prompt or "high quality photo, realistic"
737
+ kwargs = {
738
+ "prompt": prompt,
739
+ "image": image,
740
+ "strength": float(getattr(cfg, "flux_strength", 0.85)),
741
+ "num_inference_steps": int(getattr(cfg, "flux_steps", 4)),
742
+ "guidance_scale": float(getattr(cfg, "flux_guidance_scale", 0.0)),
743
+ "max_sequence_length": int(getattr(cfg, "flux_max_sequence_length", 256)),
744
+ }
745
+ with torch.inference_mode():
746
+ try:
747
+ return pipe(**kwargs).images[0]
748
+ except TypeError:
749
+ kwargs.pop("max_sequence_length", None)
750
+ return pipe(**kwargs).images[0]
751
+
752
+
753
+ def _flux_schnell_inpaint(image, binary_mask, cfg):
754
+ pipe = _get_flux_schnell_pipe(cfg.flux_model_name, cfg.device)
755
+
756
+ if not binary_mask.any():
757
+ return image
758
+
759
+ use_local_crop = bool(getattr(cfg, "diffusion_local_crop", True))
760
+ local_max_cover = float(getattr(cfg, "diffusion_local_max_cover", 0.7))
761
+ local_max_cover = min(1.0, max(0.0, local_max_cover))
762
+ mask_cover = float(binary_mask.mean())
763
+
764
+ if (not use_local_crop) or mask_cover >= local_max_cover:
765
+ gen = _run_flux_schnell(pipe, image, cfg)
766
+ out = np.asarray(image).copy()
767
+ gen_arr = np.asarray(gen.resize(image.size, Image.LANCZOS))
768
+ out[binary_mask] = gen_arr[binary_mask]
769
+ return Image.fromarray(out)
770
+
771
+ h, w = binary_mask.shape
772
+ x0, y0, x1, y1 = _mask_to_bbox(binary_mask)
773
+ crop_pad = max(0, int(getattr(cfg, "diffusion_crop_pad", 48)))
774
+ x0, y0 = max(0, x0 - crop_pad), max(0, y0 - crop_pad)
775
+ x1, y1 = min(w, x1 + crop_pad), min(h, y1 + crop_pad)
776
+ if x1 <= x0 or y1 <= y0:
777
+ return image
778
+
779
+ crop_img = image.crop((x0, y0, x1, y1))
780
+ crop_mask = binary_mask[y0:y1, x0:x1]
781
+ if not crop_mask.any():
782
+ return image
783
+
784
+ result_crop = _run_flux_schnell(pipe, crop_img, cfg).resize(crop_img.size, Image.LANCZOS)
785
+
786
+ out = np.asarray(image).copy()
787
+ roi = out[y0:y1, x0:x1]
788
+ result_arr = np.asarray(result_crop)
789
+
790
+ mh, mw = crop_mask.shape
791
+ rh2, rw2 = result_arr.shape[:2]
792
+ if (rh2, rw2) != (mh, mw):
793
+ result_arr = np.asarray(result_crop.resize((mw, mh), Image.LANCZOS))
794
+ rh2, rw2 = result_arr.shape[:2]
795
+ if roi.shape[:2] != (mh, mw) or (rh2, rw2) != (mh, mw):
796
+ hh = min(roi.shape[0], mh, rh2)
797
+ ww = min(roi.shape[1], mw, rw2)
798
+ roi = roi[:hh, :ww]
799
+ result_arr = result_arr[:hh, :ww]
800
+ crop_mask = crop_mask[:hh, :ww]
801
+
802
+ roi[crop_mask] = result_arr[crop_mask]
803
+ out[y0:y1, x0:x1] = roi
804
+ return Image.fromarray(out)
805
+
806
+
807
+ def _diffusion_inpaint(image, binary_mask, cfg):
808
+ pipe = _get_diffusion_pipe(cfg.diffusion_model_name, cfg.device)
809
+ prompt = cfg.diffusion_prompt or "high quality photo, realistic"
810
+
811
+ if not binary_mask.any():
812
+ return image
813
+
814
+ use_local_crop = bool(getattr(cfg, "diffusion_local_crop", True))
815
+ local_max_cover = float(getattr(cfg, "diffusion_local_max_cover", 0.7))
816
+ local_max_cover = min(1.0, max(0.0, local_max_cover))
817
+ mask_cover = float(binary_mask.mean())
818
+
819
+ if (not use_local_crop) or mask_cover >= local_max_cover:
820
+ orig_size = image.size
821
+ img_r = image.resize((512, 512), Image.LANCZOS)
822
+ mask_pil = Image.fromarray((binary_mask.astype(np.uint8) * 255), mode="L").resize((512, 512), Image.NEAREST)
823
+ with torch.inference_mode():
824
+ result = pipe(
825
+ prompt=prompt,
826
+ image=img_r,
827
+ mask_image=mask_pil,
828
+ num_inference_steps=cfg.diffusion_steps,
829
+ guidance_scale=7.5,
830
+ ).images[0]
831
+ return result.resize(orig_size, Image.LANCZOS)
832
+
833
+ h, w = binary_mask.shape
834
+ x0, y0, x1, y1 = _mask_to_bbox(binary_mask)
835
+ crop_pad = max(0, int(getattr(cfg, "diffusion_crop_pad", 48)))
836
+ x0, y0 = max(0, x0 - crop_pad), max(0, y0 - crop_pad)
837
+ x1, y1 = min(w, x1 + crop_pad), min(h, y1 + crop_pad)
838
+ if x1 <= x0 or y1 <= y0:
839
+ return image
840
+
841
+ crop_img = image.crop((x0, y0, x1, y1))
842
+ crop_mask = binary_mask[y0:y1, x0:x1]
843
+ if not crop_mask.any():
844
+ return image
845
+
846
+ rw, rh = crop_img.size
847
+ min_side = max(8, int(getattr(cfg, "diffusion_crop_min_side", 256)))
848
+ scale = 1.0
849
+ short_side = max(1, min(rw, rh))
850
+ if short_side < min_side:
851
+ scale = float(min_side) / float(short_side)
852
+
853
+ tw = max(8, int(round((rw * scale) / 8.0) * 8))
854
+ th = max(8, int(round((rh * scale) / 8.0) * 8))
855
+ img_r = crop_img.resize((tw, th), Image.LANCZOS)
856
+ mask_pil = Image.fromarray((crop_mask.astype(np.uint8) * 255), mode="L").resize((tw, th), Image.NEAREST)
857
+
858
+ with torch.inference_mode():
859
+ result = pipe(
860
+ prompt=prompt,
861
+ image=img_r,
862
+ mask_image=mask_pil,
863
+ num_inference_steps=cfg.diffusion_steps,
864
+ guidance_scale=7.5,
865
+ ).images[0]
866
+
867
+ result_crop = result.resize((rw, rh), Image.LANCZOS)
868
+ out = np.asarray(image).copy()
869
+ roi = out[y0:y1, x0:x1]
870
+ result_arr = np.asarray(result_crop)
871
+
872
+ # Guard against occasional off-by-one patch sizes from inpainting backends.
873
+ mh, mw = crop_mask.shape
874
+ rh2, rw2 = result_arr.shape[:2]
875
+ if (rh2, rw2) != (mh, mw):
876
+ result_arr = np.asarray(result_crop.resize((mw, mh), Image.LANCZOS))
877
+ rh2, rw2 = result_arr.shape[:2]
878
+ if roi.shape[:2] != (mh, mw) or (rh2, rw2) != (mh, mw):
879
+ hh = min(roi.shape[0], mh, rh2)
880
+ ww = min(roi.shape[1], mw, rw2)
881
+ roi = roi[:hh, :ww]
882
+ result_arr = result_arr[:hh, :ww]
883
+ crop_mask = crop_mask[:hh, :ww]
884
+
885
+ roi[crop_mask] = result_arr[crop_mask]
886
+ out[y0:y1, x0:x1] = roi
887
+ return Image.fromarray(out)
888
+
889
+
890
+ def _lama_inpaint(image, binary_mask, cfg):
891
+ pipe = _get_lama_pipe(cfg.device)
892
+
893
+ if not binary_mask.any():
894
+ return image
895
+
896
+ use_local_crop = bool(getattr(cfg, "diffusion_local_crop", True))
897
+ local_max_cover = float(getattr(cfg, "diffusion_local_max_cover", 0.7))
898
+ local_max_cover = min(1.0, max(0.0, local_max_cover))
899
+ mask_cover = float(binary_mask.mean())
900
+
901
+ if (not use_local_crop) or mask_cover >= local_max_cover:
902
+ mask_pil = Image.fromarray((binary_mask.astype(np.uint8) * 255), mode="L")
903
+ return pipe(image, mask_pil)
904
+
905
+ h, w = binary_mask.shape
906
+ x0, y0, x1, y1 = _mask_to_bbox(binary_mask)
907
+ crop_pad = max(0, int(getattr(cfg, "diffusion_crop_pad", 48)))
908
+ x0, y0 = max(0, x0 - crop_pad), max(0, y0 - crop_pad)
909
+ x1, y1 = min(w, x1 + crop_pad), min(h, y1 + crop_pad)
910
+ if x1 <= x0 or y1 <= y0:
911
+ return image
912
+
913
+ crop_img = image.crop((x0, y0, x1, y1))
914
+ crop_mask = binary_mask[y0:y1, x0:x1]
915
+ if not crop_mask.any():
916
+ return image
917
+
918
+ mask_pil = Image.fromarray((crop_mask.astype(np.uint8) * 255), mode="L")
919
+ result_crop = pipe(crop_img, mask_pil)
920
+
921
+ out = np.asarray(image).copy()
922
+ roi = out[y0:y1, x0:x1]
923
+ result_arr = np.asarray(result_crop)
924
+
925
+ # Guard against occasional off-by-one patch sizes from inpainting backends.
926
+ mh, mw = crop_mask.shape
927
+ rh2, rw2 = result_arr.shape[:2]
928
+ if (rh2, rw2) != (mh, mw):
929
+ result_arr = np.asarray(result_crop.resize((mw, mh), Image.LANCZOS))
930
+ rh2, rw2 = result_arr.shape[:2]
931
+ if roi.shape[:2] != (mh, mw) or (rh2, rw2) != (mh, mw):
932
+ hh = min(roi.shape[0], mh, rh2)
933
+ ww = min(roi.shape[1], mw, rw2)
934
+ roi = roi[:hh, :ww]
935
+ result_arr = result_arr[:hh, :ww]
936
+ crop_mask = crop_mask[:hh, :ww]
937
+
938
+ roi[crop_mask] = result_arr[crop_mask]
939
+ out[y0:y1, x0:x1] = roi
940
+ return Image.fromarray(out)
941
+
942
+
943
+ def _cv2_inpaint(image, binary_mask, method="telea"):
944
+ if cv2_mod is None:
945
+ raise ImportError("opencv-python required for cv2 inpainting")
946
+ arr = cv2_mod.cvtColor(np.asarray(image), cv2_mod.COLOR_RGB2BGR)
947
+ mask_u8 = binary_mask.astype(np.uint8) * 255
948
+ flag = cv2_mod.INPAINT_TELEA if method == "telea" else cv2_mod.INPAINT_NS
949
+ res = cv2_mod.inpaint(arr, mask_u8, inpaintRadius=5, flags=flag)
950
+ return Image.fromarray(cv2_mod.cvtColor(res, cv2_mod.COLOR_BGR2RGB))
951
+
952
+
953
+ def apply_image_mask(
954
+ image,
955
+ regions,
956
+ coalition_mask,
957
+ style="blur",
958
+ blur_radius=12,
959
+ cfg=None,
960
+ timing_meta: Optional[Dict[str, Any]] = None,
961
+ ):
962
+ """Mask image regions based on coalition vector (1=keep, 0=mask)."""
963
+ t0 = time.perf_counter()
964
+ image = _ensure_rgb(image)
965
+ arr = np.asarray(image).copy()
966
+ h, w = arr.shape[:2]
967
+ style = (style or "blur").lower()
968
+ infill_s = 0.0
969
+
970
+ if len(coalition_mask) != len(regions):
971
+ raise ValueError(f"coalition length {len(coalition_mask)} != regions {len(regions)}")
972
+
973
+ n_regions = len(regions)
974
+ n_masked_regions = sum(1 for v in coalition_mask if not int(v))
975
+
976
+ if style in ("diffusion", "lama", "lcm", "flux", "flux_schnell", "diffusion_cv2", "cv2_telea", "cv2_ns"):
977
+ combined = np.zeros((h, w), dtype=bool)
978
+ for i, reg in enumerate(regions):
979
+ if not int(coalition_mask[i]):
980
+ combined |= _region_mask(reg, (h, w))
981
+ masked_pixels = int(combined.sum())
982
+ if not combined.any():
983
+ out_img = image
984
+ elif style == "diffusion":
985
+ t_infill = time.perf_counter()
986
+ out_img = _diffusion_inpaint(image, combined, cfg)
987
+ infill_s = time.perf_counter() - t_infill
988
+ elif style == "lama":
989
+ t_infill = time.perf_counter()
990
+ out_img = _lama_inpaint(image, combined, cfg)
991
+ infill_s = time.perf_counter() - t_infill
992
+ elif style == "lcm":
993
+ t_infill = time.perf_counter()
994
+ out_img = _lcm_inpaint(image, combined, cfg)
995
+ infill_s = time.perf_counter() - t_infill
996
+ elif style in ("flux", "flux_schnell"):
997
+ t_infill = time.perf_counter()
998
+ out_img = _flux_schnell_inpaint(image, combined, cfg)
999
+ infill_s = time.perf_counter() - t_infill
1000
+ elif style in ("diffusion_cv2", "cv2_telea"):
1001
+ t_infill = time.perf_counter()
1002
+ out_img = _cv2_inpaint(image, combined, method="telea")
1003
+ infill_s = time.perf_counter() - t_infill
1004
+ else: # cv2_ns
1005
+ t_infill = time.perf_counter()
1006
+ out_img = _cv2_inpaint(image, combined, method="ns")
1007
+ infill_s = time.perf_counter() - t_infill
1008
+ total_s = time.perf_counter() - t0
1009
+ if timing_meta and timing_meta.get("recorder") is not None:
1010
+ timing_meta["recorder"].add(
1011
+ phase=str(timing_meta.get("phase") or "apply_image_mask"),
1012
+ event=str(timing_meta.get("event") or ""),
1013
+ total_s=total_s,
1014
+ infill_s=infill_s,
1015
+ n_regions=n_regions,
1016
+ n_masked_regions=n_masked_regions,
1017
+ masked_pixels=masked_pixels,
1018
+ image_hw=(h, w),
1019
+ store_detail=bool(timing_meta.get("store_detail", False)),
1020
+ )
1021
+ return out_img
1022
+
1023
+ if style == "blur":
1024
+ fill_mode = "blur"
1025
+ elif style == "gray":
1026
+ fill_mode = "gray"
1027
+ elif style == "mean":
1028
+ fill_mode = "mean"
1029
+ else:
1030
+ raise ValueError(f"Unknown mask_style: {style}")
1031
+
1032
+ combined = np.zeros((h, w), dtype=bool)
1033
+ for i, reg in enumerate(regions):
1034
+ if int(coalition_mask[i]):
1035
+ continue
1036
+ rmask = _region_mask(reg, (h, w))
1037
+ if rmask.any():
1038
+ combined |= rmask
1039
+
1040
+ masked_pixels = int(combined.sum())
1041
+ if combined.any():
1042
+ t_infill = time.perf_counter()
1043
+ if fill_mode == "blur":
1044
+ fill = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=blur_radius)))
1045
+ elif fill_mode == "gray":
1046
+ fill = np.full_like(arr, 128)
1047
+ else:
1048
+ fill = np.broadcast_to(arr.mean(axis=(0, 1)).astype(np.uint8), arr.shape).copy()
1049
+ arr[combined] = fill[combined]
1050
+ infill_s = time.perf_counter() - t_infill
1051
+
1052
+ out_img = Image.fromarray(arr)
1053
+ total_s = time.perf_counter() - t0
1054
+ if timing_meta and timing_meta.get("recorder") is not None:
1055
+ timing_meta["recorder"].add(
1056
+ phase=str(timing_meta.get("phase") or "apply_image_mask"),
1057
+ event=str(timing_meta.get("event") or ""),
1058
+ total_s=total_s,
1059
+ infill_s=infill_s,
1060
+ n_regions=n_regions,
1061
+ n_masked_regions=n_masked_regions,
1062
+ masked_pixels=masked_pixels,
1063
+ image_hw=(h, w),
1064
+ store_detail=bool(timing_meta.get("store_detail", False)),
1065
+ )
1066
+ return out_img
1067
+
1068
+
1069
+ # ═══════════════════════════════════════════════════════════════════════════
1070
+ # 4. Text masking – mask caption tokens by replacing with PAD
1071
+ # ═══════════════════════════════════════════════════════════════════════════
1072
+
1073
+ def mask_token_ids(
1074
+ full_token_ids: List[int],
1075
+ token_players: List[TokenPlayer],
1076
+ token_coalition: Sequence[int],
1077
+ unk_token_id: int,
1078
+ ) -> List[int]:
1079
+ """
1080
+ Given the full token ID list (BOS + content + EOS) and a binary coalition
1081
+ over content tokens, replace masked tokens with unk_token_id.
1082
+
1083
+ Returns a new token ID list of the same length.
1084
+ """
1085
+ # full_token_ids = [BOS, tok1, tok2, ..., tokN, EOS]
1086
+ # token_players have token_pos = 1..N (1-based positions in full_token_ids)
1087
+ result = list(full_token_ids)
1088
+ for player, keep in zip(token_players, token_coalition):
1089
+ if not int(keep):
1090
+ result[player.token_pos] = unk_token_id
1091
+ return result
1092
+
1093
+
1094
+ # ═══════════════════════════════════════════════════════════════════════════
1095
+ # 5. Cross-modal CLIP scorer
1096
+ # ═══════════════════════════════════════════════════════════════════════════
1097
+
1098
+ def _make_open_clip_scorer(model_name: str, device: str):
1099
+ """
1100
+ Load an open_clip model (e.g. BiomedCLIP) and return (model_adapter, processor_adapter)
1101
+ whose interface matches what CrossModalCLIPScorer expects from a transformers CLIPModel.
1102
+ """
1103
+ try:
1104
+ import open_clip
1105
+ except ImportError:
1106
+ raise ImportError(
1107
+ "open_clip is required for BiomedCLIP. "
1108
+ "Install with: pip install open-clip-torch"
1109
+ )
1110
+
1111
+ hf_hub_id = model_name if model_name.startswith("hf-hub:") else f"hf-hub:{model_name}"
1112
+ oc_model, _, preprocess_val = open_clip.create_model_and_transforms(hf_hub_id)
1113
+ oc_tokenizer = open_clip.get_tokenizer(hf_hub_id)
1114
+ oc_model = oc_model.to(device)
1115
+ oc_model.eval()
1116
+
1117
+ # Dict subclass that supports .to(device) like HF BatchFeature
1118
+ class _TensorDict(dict):
1119
+ def to(self, d):
1120
+ return _TensorDict(
1121
+ {k: v.to(d) if isinstance(v, torch.Tensor) else v for k, v in self.items()}
1122
+ )
1123
+
1124
+ # Tokenizer adapter — delegates to the underlying HF tokenizer when available
1125
+ class _TokAdapter:
1126
+ def __init__(self, oc_tok):
1127
+ self._hf = getattr(oc_tok, "tokenizer", None)
1128
+ self.unk_token_id = getattr(self._hf, "unk_token_id", None) or 100
1129
+ self.pad_token_id = getattr(self._hf, "pad_token_id", None) or 0
1130
+
1131
+ def __call__(self, text, add_special_tokens=True, return_tensors=None):
1132
+ if self._hf is not None:
1133
+ return self._hf(
1134
+ text, add_special_tokens=add_special_tokens, return_tensors=return_tensors
1135
+ )
1136
+ # Fallback: use open_clip tokenizer directly
1137
+ ids = oc_tokenizer([text]) # (1, context_length)
1138
+ return {"input_ids": ids}
1139
+
1140
+ def decode(self, ids, skip_special_tokens=False):
1141
+ if self._hf is not None:
1142
+ return self._hf.decode(ids, skip_special_tokens=skip_special_tokens)
1143
+ return ""
1144
+
1145
+ def encode(self, text, add_special_tokens=False):
1146
+ if self._hf is not None:
1147
+ return self._hf.encode(text, add_special_tokens=add_special_tokens)
1148
+ return []
1149
+
1150
+ # Model adapter — wraps open_clip encode_image / encode_text
1151
+ class _ModelAdapter:
1152
+ def __init__(self, m):
1153
+ self._m = m
1154
+ self.logit_scale = m.logit_scale
1155
+
1156
+ def get_image_features(self, pixel_values):
1157
+ return self._m.encode_image(pixel_values)
1158
+
1159
+ def get_text_features(self, input_ids, attention_mask=None):
1160
+ try:
1161
+ return self._m.encode_text(input_ids, attention_mask=attention_mask)
1162
+ except TypeError:
1163
+ return self._m.encode_text(input_ids)
1164
+
1165
+ def eval(self):
1166
+ self._m.eval()
1167
+ return self
1168
+
1169
+ def to(self, d):
1170
+ self._m = self._m.to(d)
1171
+ return self
1172
+
1173
+ # Processor adapter — wraps torchvision preprocess_val
1174
+ class _ProcAdapter:
1175
+ def __init__(self, pre, tok):
1176
+ self._pre = pre
1177
+ self.tokenizer = tok
1178
+
1179
+ def __call__(self, images=None, return_tensors="pt"):
1180
+ if isinstance(images, list):
1181
+ tensors = torch.stack([self._pre(img) for img in images])
1182
+ else:
1183
+ tensors = self._pre(images).unsqueeze(0)
1184
+ return _TensorDict({"pixel_values": tensors})
1185
+
1186
+ tok_adapter = _TokAdapter(oc_tokenizer)
1187
+ return _ModelAdapter(oc_model), _ProcAdapter(preprocess_val, tok_adapter)
1188
+
1189
+
1190
+ class CrossModalCLIPScorer:
1191
+ """
1192
+ CLIP scorer that supports masking BOTH image regions AND text tokens.
1193
+
1194
+ Players are indexed as:
1195
+ [0 .. n_img-1] = image regions
1196
+ [n_img .. n_img+n_tok-1] = caption tokens
1197
+ """
1198
+
1199
+ def __init__(self, cfg: PipelineConfig):
1200
+ self.device = cfg.device
1201
+ # Detect open_clip models (BiomedCLIP etc.) by name pattern
1202
+ _use_open_clip = (
1203
+ "BiomedCLIP" in cfg.clip_model_name
1204
+ or cfg.clip_model_name.startswith("hf-hub:")
1205
+ )
1206
+ if _use_open_clip:
1207
+ self.model, self.processor = _make_open_clip_scorer(
1208
+ cfg.clip_model_name, self.device
1209
+ )
1210
+ else:
1211
+ if CLIPModel is None:
1212
+ raise ImportError("transformers is required")
1213
+ self.model = CLIPModel.from_pretrained(cfg.clip_model_name).to(self.device)
1214
+ self.processor = AutoProcessor.from_pretrained(cfg.clip_model_name)
1215
+ self.model.eval()
1216
+ # Get UNK token id for text masking
1217
+ self.unk_token_id = self.processor.tokenizer.unk_token_id
1218
+ if self.unk_token_id is None:
1219
+ self.unk_token_id = 0 # fallback
1220
+ # Get PAD token id for true padding (aligning sequence lengths)
1221
+ self.pad_token_id = self.processor.tokenizer.pad_token_id
1222
+ if self.pad_token_id is None:
1223
+ self.pad_token_id = 0 # fallback
1224
+
1225
+ def score_with_masked_tokens(
1226
+ self,
1227
+ image: Image.Image,
1228
+ token_ids: List[int],
1229
+ attention_mask: Optional[List[int]] = None,
1230
+ ) -> float:
1231
+ """
1232
+ Score image against a specific token ID sequence (possibly masked).
1233
+
1234
+ This bypasses normal text processing and injects token IDs directly.
1235
+ """
1236
+ # Process image only
1237
+ image_inputs = self.processor(images=image, return_tensors="pt").to(self.device)
1238
+
1239
+ # Build text inputs manually
1240
+ input_ids = torch.tensor([token_ids], dtype=torch.long, device=self.device)
1241
+ if attention_mask is None:
1242
+ attention_mask = [1] * len(token_ids)
1243
+ attn = torch.tensor([attention_mask], dtype=torch.long, device=self.device)
1244
+
1245
+ with torch.inference_mode():
1246
+ image_embeds = self.model.get_image_features(pixel_values=image_inputs["pixel_values"])
1247
+ text_embeds = self.model.get_text_features(input_ids=input_ids, attention_mask=attn)
1248
+
1249
+ # Normalize
1250
+ image_embeds = image_embeds / image_embeds.norm(p=2, dim=-1, keepdim=True)
1251
+ text_embeds = text_embeds / text_embeds.norm(p=2, dim=-1, keepdim=True)
1252
+
1253
+ # Cosine similarity scaled by CLIP's logit_scale
1254
+ logit_scale = self.model.logit_scale.exp()
1255
+ similarity = (image_embeds @ text_embeds.T) * logit_scale
1256
+
1257
+ return similarity[0, 0].item()
1258
+
1259
+ def score_batch_with_masked_tokens(
1260
+ self,
1261
+ images: List[Image.Image],
1262
+ token_ids_list: List[List[int]],
1263
+ ) -> List[float]:
1264
+ """Batch score: multiple (image, token_ids) pairs."""
1265
+ if not images:
1266
+ return []
1267
+
1268
+ # Process all images
1269
+ image_inputs = self.processor(images=images, return_tensors="pt").to(self.device)
1270
+
1271
+ # Pad token_ids to same length
1272
+ max_len = max(len(t) for t in token_ids_list)
1273
+ padded_ids = []
1274
+ attn_masks = []
1275
+ for tids in token_ids_list:
1276
+ pad_len = max_len - len(tids)
1277
+ padded_ids.append(tids + [self.pad_token_id] * pad_len)
1278
+ attn_masks.append([1] * len(tids) + [0] * pad_len)
1279
+
1280
+ input_ids = torch.tensor(padded_ids, dtype=torch.long, device=self.device)
1281
+ attn = torch.tensor(attn_masks, dtype=torch.long, device=self.device)
1282
+
1283
+ with torch.inference_mode():
1284
+ image_embeds = self.model.get_image_features(pixel_values=image_inputs["pixel_values"])
1285
+ text_embeds = self.model.get_text_features(input_ids=input_ids, attention_mask=attn)
1286
+
1287
+ image_embeds = image_embeds / image_embeds.norm(p=2, dim=-1, keepdim=True)
1288
+ text_embeds = text_embeds / text_embeds.norm(p=2, dim=-1, keepdim=True)
1289
+
1290
+ logit_scale = self.model.logit_scale.exp()
1291
+ # Per-pair similarity (diagonal)
1292
+ sims = (image_embeds * text_embeds).sum(dim=-1) * logit_scale
1293
+
1294
+ return sims.cpu().tolist()
1295
+
1296
+
1297
+ # ═══════════════════════════════════════════════════════════════════════════
1298
+ # 6. Cross-modal set function f(S)
1299
+ # ═══════════════════════════════════════════════════════════════════════════
1300
+
1301
+ def build_cross_modal_set_function(
1302
+ image: Image.Image,
1303
+ regions: List[ImageRegion],
1304
+ token_players: List[TokenPlayer],
1305
+ full_token_ids: List[int],
1306
+ scorer: CrossModalCLIPScorer,
1307
+ cfg: PipelineConfig,
1308
+ timing_recorder: Optional[MaskTimingRecorder] = None,
1309
+ ):
1310
+ """
1311
+ Build f : {0,1}^{n_img + n_tok} → ℝ
1312
+
1313
+ The coalition vector has:
1314
+ - first n_img bits: image regions (1=keep, 0=mask/infill)
1315
+ - next n_tok bits: caption tokens (1=keep, 0=replace with PAD)
1316
+
1317
+ f(S) = CLIP_similarity(masked_image, masked_text)
1318
+ """
1319
+ n_img = len(regions)
1320
+ n_tok = len(token_players)
1321
+ n_total = n_img + n_tok
1322
+ cache: Dict[Tuple[int, ...], float] = {}
1323
+
1324
+ def _key(sel):
1325
+ return tuple(int(x) for x in sel)
1326
+
1327
+ def set_function(selection: Sequence[int]) -> float:
1328
+ if len(selection) != n_total:
1329
+ raise ValueError(f"Expected {n_total} bits, got {len(selection)}")
1330
+ key = _key(selection)
1331
+ if key in cache:
1332
+ return cache[key]
1333
+
1334
+ img_coalition = list(selection[:n_img])
1335
+ tok_coalition = list(selection[n_img:])
1336
+
1337
+ # Mask image
1338
+ masked_img = apply_image_mask(
1339
+ image, regions, img_coalition,
1340
+ style=cfg.mask_style, blur_radius=cfg.blur_radius, cfg=cfg,
1341
+ timing_meta={
1342
+ "recorder": timing_recorder,
1343
+ "phase": "set_function_single",
1344
+ "event": "",
1345
+ "store_detail": False,
1346
+ },
1347
+ )
1348
+
1349
+ # Mask text tokens
1350
+ masked_tids = mask_token_ids(full_token_ids, token_players, tok_coalition, scorer.unk_token_id)
1351
+
1352
+ val = scorer.score_with_masked_tokens(masked_img, masked_tids)
1353
+ cache[key] = val
1354
+ return val
1355
+
1356
+ def batch_set_function(selections: Sequence[Sequence[int]]) -> List[float]:
1357
+ num = len(selections)
1358
+ if num == 0:
1359
+ return []
1360
+ results = [0.0] * num
1361
+ pending = [] # (idx, key, sel)
1362
+
1363
+ for i, sel in enumerate(selections):
1364
+ key = _key(sel)
1365
+ if key in cache:
1366
+ results[i] = cache[key]
1367
+ else:
1368
+ pending.append((i, key, sel))
1369
+
1370
+ batch_sz = 16
1371
+ for start in range(0, len(pending), batch_sz):
1372
+ batch = pending[start:start + batch_sz]
1373
+ imgs = []
1374
+ tids_list = []
1375
+ for _, _, sel in batch:
1376
+ img_coal = list(sel[:n_img])
1377
+ tok_coal = list(sel[n_img:])
1378
+ imgs.append(apply_image_mask(
1379
+ image, regions, img_coal,
1380
+ style=cfg.mask_style, blur_radius=cfg.blur_radius, cfg=cfg,
1381
+ timing_meta={
1382
+ "recorder": timing_recorder,
1383
+ "phase": "set_function_batch",
1384
+ "event": "",
1385
+ "store_detail": False,
1386
+ },
1387
+ ))
1388
+ tids_list.append(mask_token_ids(full_token_ids, token_players, tok_coal, scorer.unk_token_id))
1389
+
1390
+ scores = scorer.score_batch_with_masked_tokens(imgs, tids_list)
1391
+ for (row_i, key, _), sc in zip(batch, scores):
1392
+ cache[key] = sc
1393
+ results[row_i] = sc
1394
+ return results
1395
+
1396
+ set_function.batch = batch_set_function
1397
+ return set_function
1398
+
1399
+
1400
+ # ═══════════════════════════════════════════════════════════════════════════
1401
+ # 7. ProxySPEX (same as set.py)
1402
+ # ═══════════════════════════════════════════════════════════════════════════
1403
+
1404
+ def powerset(loc, max_order=None):
1405
+ if max_order is None:
1406
+ max_order = len(loc)
1407
+ out = []
1408
+ for r in range(max_order + 1):
1409
+ for sub in combinations(loc, r):
1410
+ out.append(sub)
1411
+ return out
1412
+
1413
+
1414
+ def _print_coeff_scores(name: str, coeffs: Dict[Tuple[int, ...], float], top_k: int = 20) -> None:
1415
+ """Pretty-print Fourier/Mobius coefficient scores in terminal."""
1416
+ items = sorted(coeffs.items(), key=lambda kv: abs(float(kv[1])), reverse=True)
1417
+ total = len(items)
1418
+ if top_k > 0:
1419
+ items = items[:top_k]
1420
+ print(f" [ProxySPEX] {name} scores: showing {len(items)}/{total} terms")
1421
+ for loc, val in items:
1422
+ label = "{}" if len(loc) == 0 else str(tuple(loc))
1423
+ print(f" {label:>16s} {float(val):+.8f}")
1424
+
1425
+
1426
+ def fourier_to_mobius(fourier):
1427
+ if not fourier:
1428
+ return {}
1429
+ unscaled = defaultdict(float)
1430
+ for loc, coef in fourier.items():
1431
+ real_coef = float(np.real(coef))
1432
+ for sub in powerset(loc):
1433
+ unscaled[sub] += real_coef
1434
+ mobius = {loc: val * np.power(-2.0, len(loc)) for loc, val in unscaled.items() if abs(val) > 1e-12}
1435
+ if _PRINT_FOURIER_MOBIUS:
1436
+ _print_coeff_scores("Fourier", fourier, top_k=_PRINT_COEF_TOP_K)
1437
+ _print_coeff_scores("Mobius", mobius, top_k=_PRINT_COEF_TOP_K)
1438
+ return mobius
1439
+
1440
+
1441
+ def mobius_to_fourier(mobius):
1442
+ """
1443
+ Convert sparse Mobius coefficients to sparse Fourier coefficients.
1444
+ """
1445
+ if not mobius:
1446
+ return {}
1447
+ unscaled = defaultdict(float)
1448
+ for loc, coef in mobius.items():
1449
+ loc = tuple(sorted(loc))
1450
+ scale = float(np.real(coef)) / (2 ** len(loc))
1451
+ for sub in powerset(loc):
1452
+ unscaled[sub] += scale
1453
+ return {
1454
+ loc: val * np.power(-1.0, len(loc))
1455
+ for loc, val in unscaled.items()
1456
+ if abs(val) > 1e-12
1457
+ }
1458
+
1459
+
1460
+ def _lgboost_tree_to_fourier(tree_info):
1461
+ def combine(left, right, feat_idx):
1462
+ combined = {}
1463
+ for inter in set(left) | set(right):
1464
+ lv, rv = left.get(inter, 0.0), right.get(inter, 0.0)
1465
+ combined[inter] = (lv + rv) / 2
1466
+ new = tuple(sorted(set(inter) | {feat_idx}))
1467
+ combined[new] = (lv - rv) / 2
1468
+ return combined
1469
+
1470
+ def dfs(node):
1471
+ if "leaf_value" in node:
1472
+ return {(): node["leaf_value"]}
1473
+ return combine(dfs(node["left_child"]), dfs(node["right_child"]), node["split_feature"])
1474
+
1475
+ return dfs(tree_info["tree_structure"])
1476
+
1477
+
1478
+ def lgboost_to_fourier(model_dict):
1479
+ agg = defaultdict(float)
1480
+ for ti in model_dict["tree_info"]:
1481
+ for inter, val in _lgboost_tree_to_fourier(ti).items():
1482
+ agg[inter] += val
1483
+ return {k: v for k, v in agg.items() if v != 0.0}
1484
+
1485
+
1486
+ def proxyspex(n, budget, game, max_order=None, random_seed=0):
1487
+ if lgb is None:
1488
+ raise ImportError("lightgbm required")
1489
+ if pd is None:
1490
+ raise ImportError("pandas required")
1491
+
1492
+ rng = np.random.default_rng(random_seed)
1493
+ X = rng.integers(0, 2, size=(budget, n), dtype=np.int8)
1494
+ train_X = pd.DataFrame(X, columns=[f"f{i}" for i in range(n)]).astype(np.int8)
1495
+ y = game(X)
1496
+
1497
+ if len(train_X) < 5:
1498
+ model = lgb.LGBMRegressor(verbose=-1, n_jobs=1, random_state=random_seed, max_depth=1)
1499
+ model.fit(train_X, y)
1500
+ best = model
1501
+ else:
1502
+ base = lgb.LGBMRegressor(verbose=-1, n_jobs=1, random_state=random_seed)
1503
+ param_grid = {"max_depth": [3, 5], "n_estimators": [500, 1000], "learning_rate": [0.01, 0.1]}
1504
+ cv = KFold(n_splits=min(5, len(train_X)), shuffle=True, random_state=random_seed)
1505
+ gs = GridSearchCV(base, param_grid, scoring="r2", cv=cv, verbose=0, n_jobs=1)
1506
+ gs.fit(train_X, y)
1507
+ best = gs.best_estimator_
1508
+
1509
+ fourier = lgboost_to_fourier(best.booster_.dump_model())
1510
+ mobius = fourier_to_mobius(fourier)
1511
+ max_order = max_order if max_order is not None else n
1512
+ return {k: v for k, v in mobius.items() if len(k) <= max_order}
1513
+
1514
+
1515
+ def run_proxyspex(set_function, features, max_order=3, seed=None):
1516
+ n = len(features)
1517
+ if n == 0:
1518
+ return {}
1519
+
1520
+ def game(coalitions):
1521
+ arr = np.asarray(coalitions)
1522
+ if arr.ndim == 1:
1523
+ out = set_function(tuple(int(x) for x in arr))
1524
+ return out - np.mean(out)
1525
+ if arr.ndim == 2:
1526
+ if hasattr(set_function, "batch"):
1527
+ out = np.asarray(set_function.batch(arr), dtype=float)
1528
+ else:
1529
+ out = np.array([set_function(tuple(int(x) for x in row)) for row in arr], dtype=float)
1530
+ return out - np.mean(out)
1531
+ raise ValueError("coalitions must be 1-D or 2-D")
1532
+
1533
+ order = max(1, min(max_order, n))
1534
+ budget = int(10 * n * math.log(n + 1, 2))
1535
+ print(f" [ProxySPEX] n={n}, budget={budget}, max_order={order}")
1536
+ return proxyspex(n, budget, game, max_order=order, random_seed=seed or 0)
1537
+
1538
+
1539
+ # ═══════════════════════════════════════════════════════════════════════════
1540
+ # 8. Möbius → Shapley / Banzhaf (same as set.py)
1541
+ # ═══════════════════════════════════════════════════════════════════════════
1542
+
1543
+ def mobius_to_shapley(mobius):
1544
+ sii = {}
1545
+ for loc, coef in mobius.items():
1546
+ loc = tuple(sorted(loc))
1547
+ t = len(loc)
1548
+ for sub in powerset(loc, max_order=1):
1549
+ sii[sub] = sii.get(sub, 0.0) + float(np.real(coef)) / (1 + t - len(sub))
1550
+ return {k: v for k, v in sii.items() if len(k) == 1 and not math.isclose(v, 0.0)}
1551
+
1552
+
1553
+ def mobius_to_banzhaf(mobius):
1554
+ bii = {}
1555
+ for loc, coef in mobius.items():
1556
+ loc = tuple(sorted(loc))
1557
+ t = len(loc)
1558
+ for sub in powerset(loc, max_order=1):
1559
+ bii[sub] = bii.get(sub, 0.0) + float(np.real(coef)) / (2 ** (t - len(sub)))
1560
+ return {k: v for k, v in bii.items() if len(k) == 1 and not math.isclose(v, 0.0)}
1561
+
1562
+
1563
+ def mobius_to_influence_ii(mobius, max_order=None):
1564
+ """
1565
+ Convert Mobius coefficients to Influence interaction indices.
1566
+
1567
+ This mirrors AttrLLM_dev/attribution/utils.py logic:
1568
+ 1) mobius -> fourier
1569
+ 2) for each target location A, influence(A) = sum_{B overlaps A} |hat{f}(B)|^2
1570
+ """
1571
+ fourier = mobius_to_fourier(mobius)
1572
+ if not fourier:
1573
+ return {}
1574
+
1575
+ squared = {loc: abs(coef) ** 2 for loc, coef in fourier.items()}
1576
+ out = {}
1577
+ for target_loc in fourier.keys():
1578
+ if max_order is not None and len(target_loc) > max_order:
1579
+ continue
1580
+ target = set(target_loc)
1581
+ score = 0.0
1582
+ for loc, sq in squared.items():
1583
+ if not target.isdisjoint(loc):
1584
+ score += float(sq)
1585
+ out[tuple(sorted(target_loc))] = score
1586
+ return out
1587
+
1588
+
1589
+ def mobius_to_influence(mobius):
1590
+ iii = mobius_to_influence_ii(mobius, max_order=1)
1591
+ return {k: v for k, v in iii.items() if len(k) == 1 and not math.isclose(v, 0.0)}
1592
+
1593
+
1594
+ def influence_interactions(mobius, order=2, top_k=15, player_filter=None):
1595
+ """
1596
+ Extract top-k Influence interactions by order from mobius coefficients.
1597
+ """
1598
+ iii = mobius_to_influence_ii(mobius)
1599
+ filtered = {}
1600
+ for loc, val in iii.items():
1601
+ if len(loc) != order or math.isclose(float(val), 0.0):
1602
+ continue
1603
+ if player_filter is not None and not player_filter(loc):
1604
+ continue
1605
+ filtered[tuple(sorted(loc))] = float(val)
1606
+ top = sorted(filtered.items(), key=lambda kv: abs(kv[1]), reverse=True)
1607
+ if top_k is None or top_k <= 0:
1608
+ return top
1609
+ return top[:top_k]
1610
+
1611
+
1612
+ def extract_interactions(mobius, order=2, top_k=15, player_filter=None):
1613
+ """
1614
+ Extract top-k interaction indices of the given order.
1615
+ Works for any pair including cross-modal (image × text).
1616
+
1617
+ Args:
1618
+ player_filter: optional callable(loc) -> bool. When provided, only
1619
+ interactions for which player_filter(loc) is True are
1620
+ included. This lets callers extract e.g. only
1621
+ cross-modal pairs with their own dedicated top_k.
1622
+ """
1623
+ scores = {}
1624
+ for T, mval in mobius.items():
1625
+ T = tuple(sorted(T))
1626
+ k = len(T)
1627
+ if k < order:
1628
+ continue
1629
+ weight = 1.0 / (k - order + 1)
1630
+ for sub in combinations(T, order):
1631
+ if player_filter is not None and not player_filter(sub):
1632
+ continue
1633
+ scores[sub] = scores.get(sub, 0.0) + weight * float(np.real(mval))
1634
+ top = sorted(scores.items(), key=lambda kv: abs(kv[1]), reverse=True)
1635
+ return top[:top_k] if top_k else top
1636
+
1637
+
1638
+ def extract_cross_per_token(mobius, n_img, n_tok, order=2, per_token_k=5):
1639
+ """
1640
+ Extract cross-modal interactions with **per-token top-k** guarantee.
1641
+
1642
+ Returns:
1643
+ per_token_dict : dict[int, list] – token local index j → [(loc, val), …] (up to per_token_k)
1644
+ global_top5 : list – overall top-5 cross-modal pairs by |value|
1645
+ """
1646
+ cross_filter = lambda loc: any(i < n_img for i in loc) and any(i >= n_img for i in loc)
1647
+ # Get ALL cross-modal interactions (no top_k cap)
1648
+ all_cross = extract_interactions(mobius, order=order, top_k=0, player_filter=cross_filter)
1649
+
1650
+ # Per-token buckets
1651
+ per_token_dict: Dict[int, list] = {j: [] for j in range(n_tok)}
1652
+ for loc, val in all_cross:
1653
+ for idx in loc:
1654
+ if idx >= n_img: # this is a token player
1655
+ tok_j = idx - n_img
1656
+ if tok_j < n_tok:
1657
+ per_token_dict[tok_j].append((loc, val))
1658
+
1659
+ # Keep only top-k per token
1660
+ for j in per_token_dict:
1661
+ per_token_dict[j].sort(key=lambda x: abs(x[1]), reverse=True)
1662
+ per_token_dict[j] = per_token_dict[j][:per_token_k]
1663
+
1664
+ # Global top-5
1665
+ global_top5 = all_cross[:5]
1666
+
1667
+ return per_token_dict, global_top5
1668
+
1669
+
1670
+ # ═══════════════════════════════════════════════════════════════════════════
1671
+ # 9. Dataset loader (same as set.py)
1672
+ # ═══════════════════════════════════════════════════════════════════════════
1673
+
1674
+ def load_coco_samples(cfg):
1675
+ samples = _try_load_flickr30k(cfg)
1676
+ if samples is not None:
1677
+ return samples
1678
+ samples = _try_load_coco_urls(cfg)
1679
+ if samples is not None:
1680
+ return samples
1681
+ raise RuntimeError("Could not load any image dataset.")
1682
+
1683
+
1684
+ def _try_load_flickr30k(cfg):
1685
+ if load_dataset is None:
1686
+ return None
1687
+ try:
1688
+ print("[Dataset] Trying nlphuji/flickr30k …")
1689
+ ds = load_dataset("nlphuji/flickr30k", split="test")
1690
+ total = len(ds)
1691
+ rng = np.random.default_rng(cfg.random_seed)
1692
+ indices = rng.choice(total, size=min(cfg.num_images, total), replace=False)
1693
+ samples = []
1694
+ for idx in indices:
1695
+ row = ds[int(idx)]
1696
+ pil_img = row["image"].convert("RGB")
1697
+ cap_field = row.get("caption", row.get("captions", []))
1698
+ caption = cap_field[0] if isinstance(cap_field, list) and cap_field else (cap_field if isinstance(cap_field, str) else "a photo")
1699
+ cocoid = row.get("img_id", row.get("id", int(idx)))
1700
+ if isinstance(cocoid, str):
1701
+ cocoid = hash(cocoid) % 10**8
1702
+ samples.append({"image": pil_img, "caption": caption, "cocoid": int(cocoid)})
1703
+ print(f" loaded id={cocoid} caption='{caption[:60]}…'")
1704
+ return samples
1705
+ except Exception as e:
1706
+ print(f"[Dataset] flickr30k failed: {e}")
1707
+ return None
1708
+
1709
+
1710
+ def _try_load_coco_urls(cfg):
1711
+ import urllib.request, io, json, zipfile
1712
+ print("[Dataset] Trying direct COCO val2017 download (with real annotations) …")
1713
+ base_img_url = "http://images.cocodataset.org/val2017/"
1714
+ ann_zip_url = "http://images.cocodataset.org/annotations/annotations_trainval2017.zip"
1715
+ cache_dir = os.environ.get("HF_HOME", os.path.join(os.path.expanduser("~"), ".cache", "huggingface"))
1716
+ captions_cache = os.path.join(cache_dir, "coco_captions_val2017.json")
1717
+ id_to_captions = {}
1718
+ if os.path.isfile(captions_cache):
1719
+ with open(captions_cache) as f:
1720
+ ann_data = json.load(f)
1721
+ for ann in ann_data["annotations"]:
1722
+ id_to_captions.setdefault(ann["image_id"], []).append(ann["caption"])
1723
+ else:
1724
+ print(" Downloading COCO annotations zip (~252 MB, one-time) …")
1725
+ try:
1726
+ with urllib.request.urlopen(ann_zip_url, timeout=300) as resp:
1727
+ zip_bytes = resp.read()
1728
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
1729
+ with zf.open("annotations/captions_val2017.json") as cf:
1730
+ ann_data = json.load(cf)
1731
+ os.makedirs(os.path.dirname(captions_cache), exist_ok=True)
1732
+ with open(captions_cache, "w") as f:
1733
+ json.dump(ann_data, f)
1734
+ for ann in ann_data["annotations"]:
1735
+ id_to_captions.setdefault(ann["image_id"], []).append(ann["caption"])
1736
+ except Exception as e:
1737
+ print(f" WARN: Failed to download COCO annotations: {e}")
1738
+ return None
1739
+ if not id_to_captions:
1740
+ return None
1741
+ all_ids = list(id_to_captions.keys())
1742
+ rng = np.random.default_rng(cfg.random_seed)
1743
+ chosen = rng.choice(all_ids, size=min(cfg.num_images, len(all_ids)), replace=False)
1744
+ samples = []
1745
+ for cid in chosen:
1746
+ cid = int(cid)
1747
+ url = base_img_url + f"{cid:012d}.jpg"
1748
+ try:
1749
+ with urllib.request.urlopen(url, timeout=30) as resp:
1750
+ pil_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
1751
+ samples.append({"image": pil_img, "caption": id_to_captions[cid][0], "cocoid": cid})
1752
+ except Exception as e:
1753
+ print(f" WARN: cocoid={cid}: {e}")
1754
+ return samples or None
1755
+
1756
+
1757
+ # ═══════════════════════════════════════════════════════════════════════════
1758
+ # 10. Visualisation helpers
1759
+ # ═══════════════════════════════════════════════════════════════════════════
1760
+
1761
+ def render_segmentation_map(image, regions):
1762
+ """Colour-coded segmentation map with labels."""
1763
+ from PIL import ImageDraw, ImageFont
1764
+ import colorsys
1765
+
1766
+ image = _ensure_rgb(image)
1767
+ w, h = image.size
1768
+ base = image.copy().convert("RGBA")
1769
+ n = len(regions)
1770
+ colours = []
1771
+ for i in range(n):
1772
+ hue = (i * 0.618033988749895) % 1.0
1773
+ r, g, b = colorsys.hsv_to_rgb(hue, 0.65, 0.90)
1774
+ colours.append((int(r*255), int(g*255), int(b*255)))
1775
+
1776
+ overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
1777
+ oa = np.array(overlay)
1778
+ for i, reg in enumerate(regions):
1779
+ mask = _region_mask(reg, (h, w))
1780
+ if not mask.any(): continue
1781
+ oa[mask, :3] = colours[i]
1782
+ oa[mask, 3] = 100
1783
+ combined = Image.alpha_composite(base, Image.fromarray(oa, "RGBA"))
1784
+
1785
+ # Edges
1786
+ ea = np.zeros((h, w, 4), dtype=np.uint8)
1787
+ for i, reg in enumerate(regions):
1788
+ mask = _region_mask(reg, (h, w))
1789
+ if not mask.any(): continue
1790
+ try:
1791
+ from scipy import ndimage
1792
+ dilated = ndimage.binary_dilation(mask, iterations=2)
1793
+ except ImportError:
1794
+ dilated = mask.copy()
1795
+ for sa, sd in [(0,1),(0,-1),(1,1),(1,-1)]:
1796
+ dilated |= np.roll(mask, sd, axis=sa)
1797
+ dilated |= np.roll(mask, sd*2, axis=sa)
1798
+ edge = dilated & ~mask
1799
+ ea[edge, :3] = [max(0,c-60) for c in colours[i]]
1800
+ ea[edge, 3] = 200
1801
+ combined = Image.alpha_composite(combined, Image.fromarray(ea, "RGBA"))
1802
+
1803
+ # Labels
1804
+ draw = ImageDraw.Draw(combined)
1805
+ try:
1806
+ font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size=max(12, min(h,w)//40))
1807
+ except (IOError, OSError):
1808
+ try:
1809
+ font = ImageFont.truetype("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", size=max(12, min(h,w)//40))
1810
+ except (IOError, OSError):
1811
+ font = ImageFont.load_default()
1812
+ for i, reg in enumerate(regions):
1813
+ mask = _region_mask(reg, (h, w))
1814
+ if not mask.any(): continue
1815
+ ys, xs = np.where(mask)
1816
+ cx, cy = int(xs.mean()), int(ys.mean())
1817
+ for dx in (-1,0,1):
1818
+ for dy in (-1,0,1):
1819
+ if dx==0 and dy==0: continue
1820
+ draw.text((cx+dx,cy+dy), reg.label, fill=(0,0,0,220), font=font, anchor="mm")
1821
+ draw.text((cx,cy), reg.label, fill=(255,255,255,255), font=font, anchor="mm")
1822
+ return combined
1823
+
1824
+
1825
+ def render_overlay(image, regions, values, alpha=0.6):
1826
+ """Red/green overlay based on attribution values."""
1827
+ image = _ensure_rgb(image)
1828
+ w, h = image.size
1829
+ ov = np.zeros((h, w, 4), dtype=np.uint8)
1830
+ max_abs = max((abs(v) for v in values), default=0.0) or 1.0
1831
+ for reg, val in zip(regions, values):
1832
+ mask = _region_mask(reg, (h, w))
1833
+ if not mask.any(): continue
1834
+ norm = min(1.0, abs(val) / max_abs)
1835
+ if norm <= 0: continue
1836
+ rgb = np.array([1,109,1], dtype=np.uint8) if val >= 0 else np.array([221,19,19], dtype=np.uint8)
1837
+ ov[mask, :3] = rgb
1838
+ ov[mask, 3] = int(round(255 * alpha * norm))
1839
+ return Image.fromarray(ov, "RGBA")
1840
+
1841
+
1842
+ def save_results_mm(
1843
+ image, regions, token_players, all_labels,
1844
+ values, interactions_img, interactions_tok,
1845
+ cross_per_token, cross_global_top5,
1846
+ cross_pair_scores, mobius,
1847
+ method, cocoid, caption,
1848
+ n_img, n_tok, out_dir="results_mm",
1849
+ ):
1850
+ """Save cross-modal attribution results."""
1851
+ os.makedirs(out_dir, exist_ok=True)
1852
+
1853
+ # Image overlay (only image-region values)
1854
+ img_vals = [float(values.get((i,), 0.0)) for i in range(n_img)]
1855
+ overlay = render_overlay(image, regions, img_vals)
1856
+ base = image.convert("RGBA")
1857
+ Image.alpha_composite(base, overlay).save(os.path.join(out_dir, f"coco_{cocoid}_overlay.png"))
1858
+ image.save(os.path.join(out_dir, f"coco_{cocoid}_original.png"))
1859
+
1860
+ # Segmentation map
1861
+ try:
1862
+ render_segmentation_map(image, regions).save(os.path.join(out_dir, f"coco_{cocoid}_segmap.png"))
1863
+ except Exception as e:
1864
+ print(f" WARN: segmap: {e}")
1865
+
1866
+ # Text summary
1867
+ lines = [f"=== COCO {cocoid} – {method} CROSS-MODAL attributions ===\n"]
1868
+ if caption:
1869
+ lines.append(f"Caption: {caption}\n")
1870
+
1871
+ # Image region values
1872
+ lines.append(f"Image region values ({n_img} regions):")
1873
+ sorted_img = sorted([(i, img_vals[i]) for i in range(n_img)], key=lambda x: abs(x[1]), reverse=True)
1874
+ for idx, val in sorted_img:
1875
+ lines.append(f" {all_labels[idx]:>25s} {val:+.6f}")
1876
+
1877
+ # Token values
1878
+ tok_vals = [float(values.get((n_img + j,), 0.0)) for j in range(n_tok)]
1879
+ lines.append(f"\nCaption token values ({n_tok} tokens):")
1880
+ sorted_tok = sorted([(j, tok_vals[j]) for j in range(n_tok)], key=lambda x: abs(x[1]), reverse=True)
1881
+ for j, val in sorted_tok:
1882
+ lines.append(f" {all_labels[n_img + j]:>25s} {val:+.6f}")
1883
+
1884
+ # Each category already extracted with its own top_k
1885
+ lines.append(f"\nImage×Image interactions ({len(interactions_img)}):")
1886
+ for loc, val in interactions_img:
1887
+ names = " × ".join(all_labels[i] for i in loc)
1888
+ lines.append(f" {names:>45s} {val:+.6f}")
1889
+
1890
+ lines.append(f"\nToken×Token interactions ({len(interactions_tok)}):")
1891
+ for loc, val in interactions_tok:
1892
+ names = " × ".join(all_labels[i] for i in loc)
1893
+ lines.append(f" {names:>45s} {val:+.6f}")
1894
+
1895
+ # ── Global top-5 cross-modal interactions ──
1896
+ lines.append(f"\nTop-5 Cross-Modal interactions (seg × tok):")
1897
+ for loc, val in cross_global_top5:
1898
+ names = " × ".join(all_labels[i] for i in loc)
1899
+ lines.append(f" {names:>45s} {val:+.6f}")
1900
+
1901
+ # ── Focused cross-modal: top-2 positive tokens + bottom-2 negative tokens × top-3 segs ──
1902
+ pos_toks = [(j, tok_vals[j]) for j in range(n_tok) if tok_vals[j] > 0]
1903
+ neg_toks = [(j, tok_vals[j]) for j in range(n_tok) if tok_vals[j] < 0]
1904
+ pos_toks.sort(key=lambda x: x[1], reverse=True)
1905
+ neg_toks.sort(key=lambda x: x[1]) # most negative first
1906
+
1907
+ for rank, (pj, pv) in enumerate(pos_toks[:2], 1):
1908
+ plabel = all_labels[n_img + pj]
1909
+ lines.append(f"\n★ Positive token #{rank}: {plabel} ({pv:+.6f}) × top-3 segments:")
1910
+ for loc, val in cross_per_token.get(pj, [])[:3]:
1911
+ names = " × ".join(all_labels[i] for i in loc)
1912
+ lines.append(f" {names:>45s} {val:+.6f}")
1913
+ if not pos_toks:
1914
+ lines.append(f"\n★ Positive tokens: (none — no positive token values)")
1915
+
1916
+ for rank, (nj, nv) in enumerate(neg_toks[:2], 1):
1917
+ nlabel = all_labels[n_img + nj]
1918
+ lines.append(f"\n★ Negative token #{rank}: {nlabel} ({nv:+.6f}) × top-3 segments:")
1919
+ for loc, val in cross_per_token.get(nj, [])[:3]:
1920
+ names = " × ".join(all_labels[i] for i in loc)
1921
+ lines.append(f" {names:>45s} {val:+.6f}")
1922
+
1923
+ # ── Per-token top-5 cross-modal (full dump) ──
1924
+ lines.append(f"\n{'='*60}")
1925
+ lines.append(f"Per-token cross-modal interactions (top-5 each):")
1926
+ for j in range(n_tok):
1927
+ tlabel = all_labels[n_img + j]
1928
+ entries = cross_per_token.get(j, [])
1929
+ if not entries:
1930
+ lines.append(f"\n {tlabel}: (none)")
1931
+ continue
1932
+ lines.append(f"\n {tlabel} (Shapley={tok_vals[j]:+.6f}):")
1933
+ for loc, val in entries:
1934
+ names = " × ".join(all_labels[i] for i in loc)
1935
+ lines.append(f" {names:>45s} {val:+.6f}")
1936
+
1937
+ # ── Full seg×tok influence table (all pairs) ──
1938
+ lines.append(f"\n{'='*60}")
1939
+ lines.append("All seg×tok influence scores (complete matrix):")
1940
+ for i in range(n_img):
1941
+ for j in range(n_tok):
1942
+ loc = (i, n_img + j)
1943
+ names = f"{all_labels[i]} × {all_labels[n_img + j]}"
1944
+ val = float(cross_pair_scores.get(loc, 0.0))
1945
+ lines.append(f" {names:>45s} {val:+.6f}")
1946
+
1947
+ # ── Mobius coefficients (all terms) ──
1948
+ lines.append(f"\n{'='*60}")
1949
+ lines.append("Mobius scores (all terms, sorted by |score|):")
1950
+ sorted_mobius = sorted(
1951
+ ((tuple(sorted(loc)), float(val)) for loc, val in mobius.items()),
1952
+ key=lambda kv: abs(kv[1]),
1953
+ reverse=True,
1954
+ )
1955
+ for loc, val in sorted_mobius:
1956
+ if len(loc) == 0:
1957
+ names = "{}"
1958
+ else:
1959
+ names = " × ".join(all_labels[i] for i in loc)
1960
+ lines.append(f" {str(loc):>20s} {names:>45s} {val:+.6f}")
1961
+
1962
+ summary_path = os.path.join(out_dir, f"coco_{cocoid}_summary.txt")
1963
+ with open(summary_path, "w") as f:
1964
+ f.write("\n".join(lines))
1965
+
1966
+ # Also dump machine-readable per-pair influence scores.
1967
+ csv_path = os.path.join(out_dir, f"coco_{cocoid}_cross_pair_influence.csv")
1968
+ with open(csv_path, "w") as f:
1969
+ f.write("seg_index,seg_label,token_index,token_label,player_pair,influence_score\n")
1970
+ for i in range(n_img):
1971
+ for j in range(n_tok):
1972
+ loc = (i, n_img + j)
1973
+ pair = f"({loc[0]},{loc[1]})"
1974
+ f.write(
1975
+ f"{i},{all_labels[i]},{j},{all_labels[n_img + j]},\"{pair}\",{float(cross_pair_scores.get(loc, 0.0)):.10f}\n"
1976
+ )
1977
+
1978
+ print(f" Saved overlay → {out_dir}/coco_{cocoid}_overlay.png")
1979
+ print(f" Saved summary → {summary_path}")
1980
+ print(f" Saved pair influence CSV → {csv_path}")
1981
+
1982
+
1983
+ # ═══════════════════════════════════════════════════════════════════════════
1984
+ # Geometry helpers
1985
+ # ═══════════════════════════════════════════════════════════════════════════
1986
+
1987
+ def _ensure_rgb(img):
1988
+ return img if img.mode == "RGB" else img.convert("RGB")
1989
+
1990
+ def _mask_to_bbox(mask):
1991
+ ys, xs = np.where(mask)
1992
+ if ys.size == 0:
1993
+ return (0, 0, 0, 0)
1994
+ return (int(xs.min()), int(ys.min()), int(xs.max())+1, int(ys.max())+1)
1995
+
1996
+ def _region_mask(region, shape):
1997
+ if region.mask is not None:
1998
+ return region.mask
1999
+ h, w = shape
2000
+ x0, y0, x1, y1 = region.bbox
2001
+ m = np.zeros((h, w), dtype=bool)
2002
+ x0, x1 = max(0, min(w, x0)), max(0, min(w, x1))
2003
+ y0, y1 = max(0, min(h, y0)), max(0, min(h, y1))
2004
+ if x1 > x0 and y1 > y0:
2005
+ m[y0:y1, x0:x1] = True
2006
+ return m
2007
+
2008
+ def _linspace_edges(size, bins):
2009
+ bins = max(1, int(bins))
2010
+ return [int(round(v)) for v in np.linspace(0, size, bins + 1)]
2011
+
2012
+
2013
+ # ═══════════════════════════════════════════════════════════════════════════
2014
+ # 11. Main – cross-modal evaluation pipeline
2015
+ # ═══════════════════════════════════════════════════════════════════════════
2016
+
2017
+ def evaluate_one(
2018
+ image: Image.Image,
2019
+ caption: str,
2020
+ cocoid: int,
2021
+ cfg: PipelineConfig,
2022
+ scorer: CrossModalCLIPScorer,
2023
+ sam_pipe=None,
2024
+ out_dir: str = "results_mm",
2025
+ ) -> Dict[str, Any]:
2026
+ """Run the cross-modal pipeline on a single image."""
2027
+ print(f"\n══ Image cocoid={cocoid} (cross-modal) ══")
2028
+ timing_recorder = (
2029
+ MaskTimingRecorder(cocoid=cocoid, mask_style=cfg.mask_style, out_dir=out_dir)
2030
+ if cfg.record_mask_timing
2031
+ else None
2032
+ )
2033
+
2034
+ # Step 1: image regions
2035
+ regions = featurise(image, cfg, sam_pipe=sam_pipe)
2036
+ n_img = len(regions)
2037
+
2038
+ # Step 2: caption tokens
2039
+ token_players, full_token_ids = tokenise_caption(caption, scorer.processor, cfg, offset=n_img)
2040
+ n_tok = len(token_players)
2041
+ n_total = n_img + n_tok
2042
+
2043
+ # All player labels
2044
+ all_labels = [r.label for r in regions] + [tp.label for tp in token_players]
2045
+ print(f" Total players: {n_total} = {n_img} image + {n_tok} text")
2046
+
2047
+ # Build cross-modal f(S)
2048
+ sf = build_cross_modal_set_function(
2049
+ image, regions, token_players, full_token_ids, scorer, cfg,
2050
+ timing_recorder=timing_recorder,
2051
+ )
2052
+
2053
+ # Sanity check
2054
+ f_full = sf(tuple([1] * n_total))
2055
+ f_empty = sf(tuple([0] * n_total))
2056
+ f_img_only = sf(tuple([1]*n_img + [0]*n_tok))
2057
+ f_txt_only = sf(tuple([0]*n_img + [1]*n_tok))
2058
+ print(f" f(all) = {f_full:.4f}")
2059
+ print(f" f(∅) = {f_empty:.4f}")
2060
+ print(f" f(img only) = {f_img_only:.4f}")
2061
+ print(f" f(txt only) = {f_txt_only:.4f}")
2062
+
2063
+ # ── Save intermediate masked images for visual comparison ──
2064
+ masked_dir = os.path.join(out_dir, f"coco_{cocoid}_masked_{cfg.mask_style}")
2065
+ os.makedirs(masked_dir, exist_ok=True)
2066
+
2067
+ # 1. Fully masked image (all regions removed)
2068
+ all_masked = apply_image_mask(image, regions, [0]*n_img,
2069
+ style=cfg.mask_style, blur_radius=cfg.blur_radius, cfg=cfg,
2070
+ timing_meta={
2071
+ "recorder": timing_recorder,
2072
+ "phase": "viz_mask",
2073
+ "event": "all_masked",
2074
+ "store_detail": True,
2075
+ })
2076
+ all_masked.save(os.path.join(masked_dir, "all_masked.png"))
2077
+
2078
+ # 2. Each region kept alone (solo), others masked
2079
+ for i, reg in enumerate(regions):
2080
+ solo = [0]*n_img
2081
+ solo[i] = 1
2082
+ img_solo = apply_image_mask(image, regions, solo,
2083
+ style=cfg.mask_style, blur_radius=cfg.blur_radius, cfg=cfg,
2084
+ timing_meta={
2085
+ "recorder": timing_recorder,
2086
+ "phase": "viz_mask",
2087
+ "event": f"{reg.label}_solo",
2088
+ "store_detail": True,
2089
+ })
2090
+ img_solo.save(os.path.join(masked_dir, f"{reg.label}_solo.png"))
2091
+
2092
+ # 3. Each region removed (others kept)
2093
+ for i, reg in enumerate(regions):
2094
+ removed = [1]*n_img
2095
+ removed[i] = 0
2096
+ img_removed = apply_image_mask(image, regions, removed,
2097
+ style=cfg.mask_style, blur_radius=cfg.blur_radius, cfg=cfg,
2098
+ timing_meta={
2099
+ "recorder": timing_recorder,
2100
+ "phase": "viz_mask",
2101
+ "event": f"{reg.label}_removed",
2102
+ "store_detail": True,
2103
+ })
2104
+ img_removed.save(os.path.join(masked_dir, f"{reg.label}_removed.png"))
2105
+
2106
+ print(f" Saved {1 + 2*n_img} masked images → {masked_dir}/")
2107
+
2108
+ # ProxySPEX
2109
+ mobius = run_proxyspex(sf, all_labels, max_order=cfg.max_order, seed=cfg.random_seed)
2110
+
2111
+ # Shapley / Banzhaf / Influence singleton values
2112
+ if cfg.method == "shapley":
2113
+ values = mobius_to_shapley(mobius)
2114
+ elif cfg.method == "banzhaf":
2115
+ values = mobius_to_banzhaf(mobius)
2116
+ else:
2117
+ values = mobius_to_influence(mobius)
2118
+
2119
+ # Extract interactions by category, each with its own top_k ──────────
2120
+ # This avoids the old bug where a single global top_k=15 was shared
2121
+ # across img×img, tok×tok, and cross-modal, causing most cross-modal
2122
+ # pairs to be silently dropped.
2123
+ img_img_filter = lambda loc: all(i < n_img for i in loc)
2124
+ tok_tok_filter = lambda loc: all(i >= n_img for i in loc)
2125
+ cross_filter = lambda loc: any(i < n_img for i in loc) and any(i >= n_img for i in loc)
2126
+
2127
+ if cfg.method == "influence":
2128
+ interactions_img = influence_interactions(
2129
+ mobius, order=cfg.max_order, top_k=cfg.top_k_interactions, player_filter=img_img_filter
2130
+ )
2131
+ interactions_tok = influence_interactions(
2132
+ mobius, order=cfg.max_order, top_k=cfg.top_k_interactions, player_filter=tok_tok_filter
2133
+ )
2134
+ else:
2135
+ interactions_img = extract_interactions(
2136
+ mobius, order=cfg.max_order, top_k=cfg.top_k_interactions, player_filter=img_img_filter
2137
+ )
2138
+ interactions_tok = extract_interactions(
2139
+ mobius, order=cfg.max_order, top_k=cfg.top_k_interactions, player_filter=tok_tok_filter
2140
+ )
2141
+ # Cross-modal: per-token top-5 (so no token is silently dropped)
2142
+ # Always compute complete pairwise cross-modal influence scores for export.
2143
+ all_cross_influence = influence_interactions(mobius, order=2, top_k=0, player_filter=cross_filter)
2144
+ all_cross_influence_dict = {tuple(sorted(loc)): float(val) for loc, val in all_cross_influence}
2145
+
2146
+ if cfg.method == "influence":
2147
+ all_cross = all_cross_influence
2148
+
2149
+ cross_per_token = {j: [] for j in range(n_tok)}
2150
+ for loc, val in all_cross:
2151
+ for idx in loc:
2152
+ if idx >= n_img:
2153
+ tok_j = idx - n_img
2154
+ if tok_j < n_tok:
2155
+ cross_per_token[tok_j].append((loc, val))
2156
+ for j in cross_per_token:
2157
+ cross_per_token[j].sort(key=lambda x: abs(x[1]), reverse=True)
2158
+ cross_per_token[j] = cross_per_token[j][:5]
2159
+ cross_global_top5 = all_cross[:5]
2160
+ else:
2161
+ cross_per_token, cross_global_top5 = extract_cross_per_token(
2162
+ mobius, n_img, n_tok, order=cfg.max_order, per_token_k=5,
2163
+ )
2164
+
2165
+ # Build a complete seg×tok influence matrix (every pair has a score; missing -> 0).
2166
+ cross_pair_scores = {}
2167
+ for i in range(n_img):
2168
+ for j in range(n_tok):
2169
+ pair = tuple(sorted((i, n_img + j)))
2170
+ cross_pair_scores[(i, n_img + j)] = float(all_cross_influence_dict.get(pair, 0.0))
2171
+
2172
+ # Combined list (for backward compat in return value)
2173
+ # Deduplicate the union of all per-token lists
2174
+ _seen = set()
2175
+ cross_interactions_all = []
2176
+ for j in range(n_tok):
2177
+ for item in cross_per_token[j]:
2178
+ key = (item[0], item[1])
2179
+ if item[0] not in _seen:
2180
+ _seen.add(item[0])
2181
+ cross_interactions_all.append(item)
2182
+ interactions = interactions_img + interactions_tok + cross_interactions_all
2183
+
2184
+ # Print summary
2185
+ print(f"\n {cfg.method.upper()} values (image regions):")
2186
+ for i in range(n_img):
2187
+ v = values.get((i,), 0.0)
2188
+ if not math.isclose(v, 0.0):
2189
+ print(f" {all_labels[i]:>20s} {v:+.6f}")
2190
+
2191
+ print(f"\n {cfg.method.upper()} values (caption tokens):")
2192
+ for j in range(n_tok):
2193
+ v = values.get((n_img + j,), 0.0)
2194
+ if not math.isclose(v, 0.0):
2195
+ print(f" {all_labels[n_img + j]:>20s} {v:+.6f}")
2196
+
2197
+ # ── Global top-5 cross-modal interactions ──
2198
+ print(f"\n Top-5 cross-modal interactions (seg × tok):")
2199
+ for loc, v in cross_global_top5:
2200
+ names = " × ".join(all_labels[i] for i in loc)
2201
+ print(f" {names:>35s} {v:+.6f}")
2202
+
2203
+ # ── Focused cross-modal: top-2 pos / bottom-2 neg tokens × top-3 segments ──
2204
+ tok_val_list = [(j, float(values.get((n_img + j,), 0.0))) for j in range(n_tok)]
2205
+ pos_toks = sorted([(j, v) for j, v in tok_val_list if v > 0], key=lambda x: -x[1])
2206
+ neg_toks = sorted([(j, v) for j, v in tok_val_list if v < 0], key=lambda x: x[1])
2207
+
2208
+ for rank, (pj, pv) in enumerate(pos_toks[:2], 1):
2209
+ print(f"\n ★ Positive token #{rank}: {all_labels[n_img+pj]} ({pv:+.6f}) × top-3 segs:")
2210
+ for loc, v in cross_per_token[pj][:3]:
2211
+ names = " × ".join(all_labels[i] for i in loc)
2212
+ print(f" {names:>35s} {v:+.6f}")
2213
+
2214
+ for rank, (nj, nv) in enumerate(neg_toks[:2], 1):
2215
+ print(f"\n ★ Negative token #{rank}: {all_labels[n_img+nj]} ({nv:+.6f}) × top-3 segs:")
2216
+ for loc, v in cross_per_token[nj][:3]:
2217
+ names = " × ".join(all_labels[i] for i in loc)
2218
+ print(f" {names:>35s} {v:+.6f}")
2219
+
2220
+ # Save
2221
+ save_results_mm(
2222
+ image, regions, token_players, all_labels,
2223
+ values, interactions_img, interactions_tok,
2224
+ cross_per_token, cross_global_top5,
2225
+ cross_pair_scores, mobius,
2226
+ cfg.method, cocoid, caption,
2227
+ n_img, n_tok, out_dir=out_dir,
2228
+ )
2229
+
2230
+ timing_detail_path = None
2231
+ timing_summary_path = None
2232
+ if timing_recorder is not None:
2233
+ timing_detail_path, timing_summary_path = timing_recorder.write_csvs()
2234
+ print(f" Saved mask timing details → {timing_detail_path}")
2235
+ print(f" Saved mask timing summary → {timing_summary_path}")
2236
+
2237
+ return {
2238
+ "cocoid": cocoid,
2239
+ "n_img": n_img,
2240
+ "n_tok": n_tok,
2241
+ "all_labels": all_labels,
2242
+ "mobius": mobius,
2243
+ "values": values,
2244
+ "interactions": interactions,
2245
+ "mask_timing_detail_path": timing_detail_path,
2246
+ "mask_timing_summary_path": timing_summary_path,
2247
+ }
2248
+
2249
+
2250
+ def main():
2251
+ parser = argparse.ArgumentParser(
2252
+ description="Cross-modal image×text attribution via mask-and-CLIP with ProxySPEX"
2253
+ )
2254
+ parser.add_argument("--mode", choices=["unsam", "sam", "patch"], default="unsam")
2255
+ parser.add_argument("--grid", type=int, default=8)
2256
+ parser.add_argument("--segment_level", choices=["all", "coarse", "medium", "fine"], default="all")
2257
+ parser.add_argument("--mask_style", choices=["blur", "gray", "mean", "diffusion", "lama", "lcm", "flux", "flux_schnell", "diffusion_cv2", "cv2_telea", "cv2_ns"], default="blur")
2258
+ parser.add_argument("--diffusion_model", default="runwayml/stable-diffusion-inpainting")
2259
+ parser.add_argument("--diffusion_prompt", default="")
2260
+ parser.add_argument("--diffusion_steps", type=int, default=20)
2261
+ parser.add_argument("--diffusion_crop_pad", type=int, default=48)
2262
+ parser.add_argument("--diffusion_crop_min_side", type=int, default=256)
2263
+ parser.add_argument("--diffusion_local_max_cover", type=float, default=0.7)
2264
+ parser.add_argument("--diffusion_local_crop", dest="diffusion_local_crop", action="store_true")
2265
+ parser.add_argument("--no_diffusion_local_crop", dest="diffusion_local_crop", action="store_false")
2266
+ parser.set_defaults(diffusion_local_crop=True)
2267
+ parser.add_argument("--lcm_base_model", default="diffusers/stable-diffusion-xl-1.0-inpainting-0.1",
2268
+ help="Base SDXL inpainting model for LCM-LoRA")
2269
+ parser.add_argument("--lcm_lora_id", default="latent-consistency/lcm-lora-sdxl",
2270
+ help="LCM-LoRA adapter id for SDXL")
2271
+ parser.add_argument("--lcm_steps", type=int, default=4,
2272
+ help="Inference steps for LCM-LoRA (recommended 4)")
2273
+ parser.add_argument("--lcm_guidance_scale", type=float, default=1.0,
2274
+ help="Guidance scale for LCM-LoRA")
2275
+ parser.add_argument("--flux_model", default="black-forest-labs/FLUX.1-schnell",
2276
+ help="FLUX.1-Schnell model id")
2277
+ parser.add_argument("--flux_steps", type=int, default=4,
2278
+ help="Inference steps for FLUX.1-Schnell")
2279
+ parser.add_argument("--flux_guidance_scale", type=float, default=0.0,
2280
+ help="Guidance scale for FLUX.1-Schnell")
2281
+ parser.add_argument("--flux_strength", type=float, default=0.85,
2282
+ help="Img2img strength for FLUX.1-Schnell")
2283
+ parser.add_argument("--flux_max_sequence_length", type=int, default=256,
2284
+ help="Max sequence length for FLUX.1-Schnell")
2285
+ parser.add_argument("--method", choices=["shapley", "banzhaf", "influence"], default="shapley")
2286
+ parser.add_argument("--num_images", type=int, default=1)
2287
+ parser.add_argument("--max_regions", type=int, default=16, help="Max image regions (default lower for cross-modal)")
2288
+ parser.add_argument("--max_tokens", type=int, default=15, help="Max caption tokens to include as players")
2289
+ parser.add_argument("--max_order", type=int, default=2)
2290
+ parser.add_argument("--top_k", type=int, default=15)
2291
+ parser.add_argument("--seed", type=int, default=42)
2292
+ parser.add_argument("--split", default="train")
2293
+ parser.add_argument("--clip_model", default="openai/clip-vit-base-patch32")
2294
+ parser.add_argument("--sam_model", default="yujunwei04/unsam-whole-image-segmentation")
2295
+ parser.add_argument("--out_dir", default="results_mm")
2296
+ parser.add_argument("--record_mask_timing", action="store_true",
2297
+ help="Write per-mask timing CSVs (total vs infill time)")
2298
+ parser.add_argument("--print_fourier_mobius", action="store_true",
2299
+ help="Print Fourier/Mobius coefficient scores from ProxySPEX")
2300
+ parser.add_argument("--print_coeff_top_k", type=int, default=20,
2301
+ help="How many Fourier/Mobius terms to print (<=0 prints all)")
2302
+ args = parser.parse_args()
2303
+
2304
+ cfg = PipelineConfig(
2305
+ coco_split=args.split,
2306
+ num_images=args.num_images,
2307
+ mode=args.mode,
2308
+ sam_model_name=args.sam_model,
2309
+ segment_level=args.segment_level,
2310
+ grid_size=args.grid,
2311
+ max_regions=args.max_regions,
2312
+ mask_style=args.mask_style,
2313
+ clip_model_name=args.clip_model,
2314
+ method=args.method,
2315
+ max_order=args.max_order,
2316
+ top_k_interactions=args.top_k,
2317
+ random_seed=args.seed,
2318
+ diffusion_model_name=args.diffusion_model,
2319
+ diffusion_prompt=args.diffusion_prompt,
2320
+ diffusion_steps=args.diffusion_steps,
2321
+ diffusion_local_crop=args.diffusion_local_crop,
2322
+ diffusion_crop_pad=args.diffusion_crop_pad,
2323
+ diffusion_crop_min_side=args.diffusion_crop_min_side,
2324
+ diffusion_local_max_cover=args.diffusion_local_max_cover,
2325
+ lcm_base_model_name=args.lcm_base_model,
2326
+ lcm_lora_id=args.lcm_lora_id,
2327
+ lcm_steps=args.lcm_steps,
2328
+ lcm_guidance_scale=args.lcm_guidance_scale,
2329
+ flux_model_name=args.flux_model,
2330
+ flux_steps=args.flux_steps,
2331
+ flux_guidance_scale=args.flux_guidance_scale,
2332
+ flux_strength=args.flux_strength,
2333
+ flux_max_sequence_length=args.flux_max_sequence_length,
2334
+ max_tokens=args.max_tokens,
2335
+ record_mask_timing=args.record_mask_timing,
2336
+ print_fourier_mobius=args.print_fourier_mobius,
2337
+ print_coeff_top_k=args.print_coeff_top_k,
2338
+ )
2339
+
2340
+ global _PRINT_FOURIER_MOBIUS, _PRINT_COEF_TOP_K
2341
+ _PRINT_FOURIER_MOBIUS = bool(cfg.print_fourier_mobius)
2342
+ _PRINT_COEF_TOP_K = int(cfg.print_coeff_top_k)
2343
+
2344
+ print("=" * 65)
2345
+ print(" Cross-Modal Image×Text Attribution Pipeline")
2346
+ print(" Players: image regions + caption tokens")
2347
+ print(" f(S) = CLIP(masked_image, masked_caption)")
2348
+ print("=" * 65)
2349
+ print(f" Mode : {cfg.mode}")
2350
+ print(f" Mask style : {cfg.mask_style}")
2351
+ print(f" Method : {cfg.method}")
2352
+ print(f" CLIP model : {cfg.clip_model_name}")
2353
+ if cfg.mode in ("sam", "unsam"):
2354
+ print(f" Seg model : {cfg.sam_model_name}")
2355
+ print(f" Seg level : {cfg.segment_level}")
2356
+ else:
2357
+ print(f" Grid size : {cfg.grid_size}")
2358
+ print(f" Max regions : {cfg.max_regions}")
2359
+ print(f" Max tokens : {cfg.max_tokens}")
2360
+ print(f" Max order : {cfg.max_order}")
2361
+ print(f" Mask timing : {cfg.record_mask_timing}")
2362
+ print(f" Print F/M : {cfg.print_fourier_mobius}")
2363
+ print(f" Coef top-k : {cfg.print_coeff_top_k}")
2364
+ print(f" Device : {cfg.device}")
2365
+ print()
2366
+
2367
+ samples = load_coco_samples(cfg)
2368
+
2369
+ print("\n[Models] Loading CLIP (cross-modal scorer)…")
2370
+ scorer = CrossModalCLIPScorer(cfg)
2371
+
2372
+ sam_pipe = None
2373
+ if cfg.mode in ("sam", "unsam"):
2374
+ print("[Models] Loading segmentation model…")
2375
+ sam_pipe = _load_sam_pipeline(cfg)
2376
+
2377
+ all_results = []
2378
+ for sample in samples:
2379
+ result = evaluate_one(
2380
+ image=sample["image"],
2381
+ caption=sample["caption"],
2382
+ cocoid=sample["cocoid"],
2383
+ cfg=cfg,
2384
+ scorer=scorer,
2385
+ sam_pipe=sam_pipe,
2386
+ out_dir=args.out_dir,
2387
+ )
2388
+ all_results.append(result)
2389
+
2390
+ print("\n" + "=" * 65)
2391
+ print(f" Done. Processed {len(all_results)} image(s).")
2392
+ print(f" Results saved to ./{args.out_dir}/")
2393
+ print("=" * 65)
2394
+
2395
+
2396
+ if __name__ == "__main__":
2397
+ main()
attribution/utils.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from itertools import combinations
6
+ from typing import Dict, List, Sequence, Tuple, Union
7
+
8
+ import numpy as np
9
+
10
+
11
+ @dataclass
12
+ class AttributionResult:
13
+ """Container for a single feature or interaction attribution value."""
14
+
15
+ feature: Union[str, Tuple[str, ...]]
16
+ value: float
17
+ method: str
18
+ interaction_order: int
19
+
20
+
21
+ def powerset(loc_tuple: Tuple[int, ...], max_order: int | None = None) -> List[Tuple[int, ...]]:
22
+ """
23
+ Generate the powerset of a sparse location tuple up to a specified maximum order.
24
+
25
+ Parameters:
26
+ - loc_tuple: A sparse tuple of feature indices (e.g., (0, 2) means features 0 and 2).
27
+ - max_order: The maximum order of the powerset (default is None).
28
+
29
+ Returns:
30
+ - A list of tuples representing the powerset in sparse format.
31
+ """
32
+ # loc_tuple is already sparse (contains feature indices)
33
+ # e.g., (0, 2) means features 0 and 2 are active
34
+
35
+ if max_order is None:
36
+ max_order = len(loc_tuple)
37
+
38
+ # Generate all subsets of the sparse indices up to max_order
39
+ tuples = []
40
+ for r in range(max_order + 1):
41
+ for subset in combinations(loc_tuple, r):
42
+ tuples.append(subset)
43
+
44
+ return tuples
45
+
46
+
47
+ def fourier_to_mobius(fourier_dict: Dict[Tuple[int, ...], float]) -> Dict[Tuple[int, ...], float]:
48
+ """
49
+ Convert Fourier coefficients to Mobius coefficients.
50
+
51
+ Parameters:
52
+ - fourier_dict: A dictionary of Fourier coefficients.
53
+
54
+ Returns:
55
+ - A dictionary of Mobius coefficients.
56
+ """
57
+ if len(fourier_dict) == 0:
58
+ return {}
59
+ else:
60
+ unscaled_mobius_dict = {}
61
+ for loc, coef in fourier_dict.items():
62
+ real_coef = np.real(coef)
63
+ for subset in powerset(loc):
64
+ if subset in unscaled_mobius_dict:
65
+ unscaled_mobius_dict[subset] += real_coef
66
+ else:
67
+ unscaled_mobius_dict[subset] = real_coef
68
+
69
+ # multiply each entry by (-2)^(cardinality)
70
+ return {loc: val * np.power(-2.0, len(loc)) for loc, val in unscaled_mobius_dict.items() if
71
+ np.abs(val) > 1e-12}
72
+
73
+ def mobius_to_fourier(mobius_dict: Dict[Tuple[int, ...], float]) -> Dict[Tuple[int, ...], float]:
74
+ """
75
+ Convert Mobius coefficients to Fourier coefficients.
76
+
77
+ Parameters:
78
+ - mobius_dict: A dictionary of Mobius coefficients.
79
+
80
+ Returns:
81
+ - A dictionary of Fourier coefficients.
82
+ """
83
+ if len(mobius_dict) == 0:
84
+ return {}
85
+ else:
86
+ unscaled_fourier_dict = {}
87
+ for loc, coef in mobius_dict.items():
88
+ # Sparse tuples store feature indices, so cardinality is len(loc), not sum(loc).
89
+ real_coef = np.real(coef) / (2 ** len(loc))
90
+ for subset in powerset(loc):
91
+ if subset in unscaled_fourier_dict:
92
+ unscaled_fourier_dict[subset] += real_coef
93
+ else:
94
+ unscaled_fourier_dict[subset] = real_coef
95
+
96
+ # multiply each entry by (-1)^(cardinality)
97
+ return {loc: val * np.power(-1.0, len(loc)) for loc, val in unscaled_fourier_dict.items() if
98
+ np.abs(val) > 1e-12}
99
+
100
+
101
+ def mobius_to_shapley_ii(
102
+ mobius_dict: Dict[Tuple[int, ...], complex],
103
+ max_order: int | None = None,
104
+ **kwargs,
105
+ ) -> Dict[Tuple[int, ...], float]:
106
+ """
107
+ Convert Mobius coefficients to Shapley interaction indices.
108
+
109
+ WARNING: This function can be expensive (exponential in feature count).
110
+ For large feature sets, consider using sparse pairwise extraction instead.
111
+
112
+ Args:
113
+ mobius_dict: Mobius coefficients keyed by sparse tuples of indices.
114
+ max_order: Maximum interaction order to compute (limits powerset size)
115
+ """
116
+ sii_dict: Dict[Tuple[int, ...], float] = {}
117
+
118
+ for loc, coef in mobius_dict.items():
119
+ loc = tuple(sorted(loc))
120
+ real_coef = float(np.real(coef))
121
+ t_size = len(loc)
122
+ for subset in powerset(loc, max_order=max_order):
123
+ contribution = real_coef / (1 + t_size - len(subset))
124
+ sii_dict[subset] = sii_dict.get(subset, 0.0) + contribution
125
+ return sii_dict
126
+
127
+
128
+ def mobius_to_banzhaf_ii(
129
+ mobius_dict: Dict[Tuple[int, ...], complex],
130
+ max_order: int | None = None,
131
+ **kwargs,
132
+ ) -> Dict[Tuple[int, ...], float]:
133
+ """
134
+ Convert Mobius coefficients to Banzhaf interaction indices.
135
+
136
+ WARNING: This function can be expensive (exponential in feature count).
137
+ For large feature sets, consider using sparse pairwise extraction instead.
138
+
139
+ Args:
140
+ mobius_dict: Mobius coefficients keyed by sparse tuples of indices.
141
+ max_order: Maximum interaction order to compute (limits powerset size)
142
+ """
143
+ bii_dict: Dict[Tuple[int, ...], float] = {}
144
+
145
+ for loc, coef in mobius_dict.items():
146
+ loc = tuple(sorted(loc))
147
+ real_coef = float(np.real(coef))
148
+ t_size = len(loc)
149
+ for subset in powerset(loc, max_order=max_order):
150
+ contribution = real_coef / math.pow(2.0, t_size - len(subset))
151
+ bii_dict[subset] = bii_dict.get(subset, 0.0) + contribution
152
+
153
+ return bii_dict
154
+
155
+ def mobius_to_influence_ii(
156
+ mobius_dict: Dict[Tuple[int, ...], complex],
157
+ max_order: int | None = None,
158
+ **kwargs,
159
+ ) -> Dict[Tuple[int, ...], float]:
160
+ """
161
+ Convert Mobius coefficients to Influence interaction indices.
162
+
163
+ WARNING: This function can be expensive (exponential in feature count).
164
+ For large feature sets, consider using sparse pairwise extraction instead.
165
+
166
+ Args:
167
+ mobius_dict: Mobius coefficients keyed by sparse tuples of indices.
168
+ max_order: Maximum interaction order to compute (limits powerset size)
169
+ """
170
+ subset_influences: Dict[Tuple[int, ...], float] = {}
171
+
172
+ fourier_dict = mobius_to_fourier(mobius_dict)
173
+
174
+ # Step 1: Precompute the squared magnitude for every location
175
+ # (Doing this once saves us from recalculating abs()**2 inside the loop)
176
+ squared_coefs = {loc: abs(coef) ** 2 for loc, coef in fourier_dict.items()}
177
+
178
+ # Step 2: For every nonzero location, calculate its subset influence
179
+ for target_loc in fourier_dict.keys():
180
+ target_set = set(target_loc)
181
+ influence = 0.0
182
+
183
+ # Sum the squared coefficients of any location that overlaps with the target
184
+ for loc, sq_coef in squared_coefs.items():
185
+ if not target_set.isdisjoint(loc):
186
+ influence += sq_coef
187
+
188
+ subset_influences[target_loc] = influence
189
+
190
+ return subset_influences
191
+
192
+
193
+ def _filter_order(values: Dict[Tuple[int, ...], float], order: int) -> Dict[Tuple[int, ...], float]:
194
+ return {
195
+ loc: val
196
+ for loc, val in values.items()
197
+ if len(loc) == order and not math.isclose(val, 0.0)
198
+ }
199
+
200
+
201
+ def _top_k(items: Dict[Tuple[int, ...], float], top_k: int) -> List[Tuple[Tuple[int, ...], float]]:
202
+ sorted_items = sorted(items.items(), key=lambda kv: abs(kv[1]), reverse=True)
203
+ if top_k is None or top_k <= 0:
204
+ return sorted_items
205
+ return sorted_items[:top_k]
206
+
207
+
208
+ def mobius_to_shapley(mobius_dict: Dict[Tuple[int, ...], complex]) -> Dict[Tuple[int, ...], float]:
209
+ """
210
+ Convert Mobius coefficients to singleton Shapley values.
211
+
212
+ Optimized: Only computes up to order=1 (singletons) to avoid exponential blow-up.
213
+ """
214
+ # Only compute singletons - no need for higher-order powersets
215
+ sii = mobius_to_shapley_ii(mobius_dict, max_order=1)
216
+ return _filter_order(sii, order=1)
217
+
218
+
219
+ def shapley_interactions(
220
+ mobius_dict: Dict[Tuple[int, ...], complex],
221
+ order: int = 2,
222
+ top_k: int = 10,
223
+ ) -> List[Tuple[Tuple[int, ...], float]]:
224
+ """
225
+ Extract the top-k Shapley interaction indices of a given order.
226
+
227
+ For order=2 (pairwise), uses sparse superset aggregation to avoid exponential blow-up.
228
+ For order>2, falls back to full conversion (may be expensive).
229
+
230
+ Formula for pairwise (|S|=2): φ_shapley(S) = Σ_{T ⊇ S} m(T) / (|T| - |S| + 1)
231
+ """
232
+ if order == 2:
233
+ # SPARSE PATH: Sparse superset aggregation for pairwise interactions
234
+ # Formula: φ_shapley(S) = Σ_{T ⊇ S} m(T) / (|T| - |S| + 1) = Σ_{T ⊇ S} m(T) / (|T| - 1)
235
+ assert order == 2, "Only pairwise supported for sparse path"
236
+
237
+ if not mobius_dict:
238
+ return []
239
+
240
+ scores: Dict[Tuple[int, ...], float] = {}
241
+
242
+ for T, mval in mobius_dict.items():
243
+ T = tuple(sorted(T))
244
+ k = len(T)
245
+ if k < order:
246
+ continue
247
+ # Weight: 1 / (|T| - |S| + 1) = 1 / (k - 2 + 1) = 1 / (k - 1)
248
+ weight = 1.0 / (k - order + 1)
249
+ for subset in combinations(T, order):
250
+ scores[subset] = scores.get(subset, 0.0) + weight * float(np.real(mval))
251
+
252
+ return _top_k(scores, top_k)
253
+ else:
254
+ # FULL PATH: Use traditional conversion for higher orders
255
+ sii = mobius_to_shapley_ii(mobius_dict)
256
+ filtered = _filter_order(sii, order=order)
257
+ return _top_k(filtered, top_k)
258
+
259
+
260
+ def mobius_to_banzhaf(mobius_dict: Dict[Tuple[int, ...], complex]) -> Dict[Tuple[int, ...], float]:
261
+ """
262
+ Convert Mobius coefficients to singleton Banzhaf values.
263
+
264
+ Optimized: Only computes up to order=1 (singletons) to avoid exponential blow-up.
265
+ """
266
+ # Only compute singletons - no need for higher-order powersets
267
+ bii = mobius_to_banzhaf_ii(mobius_dict, max_order=1)
268
+ return _filter_order(bii, order=1)
269
+
270
+
271
+ def banzhaf_interactions(
272
+ mobius_dict: Dict[Tuple[int, ...], complex],
273
+ order: int = 2,
274
+ top_k: int = 10,
275
+ ) -> List[Tuple[Tuple[int, ...], float]]:
276
+ """
277
+ Extract the top-k Banzhaf interaction indices of a given order.
278
+
279
+ For order=2 (pairwise), uses sparse superset aggregation to avoid exponential blow-up.
280
+ For order>2, falls back to full conversion (may be expensive).
281
+
282
+ Formula for pairwise (|S|=2): β_banzhaf(S) = Σ_{T ⊇ S} m(T) / 2^(|T| - |S|)
283
+ """
284
+ if order == 2:
285
+ # SPARSE PATH: Sparse superset aggregation for pairwise interactions
286
+ # Formula: β_banzhaf(S) = Σ_{T ⊇ S} m(T) / 2^(|T| - |S|) = Σ_{T ⊇ S} m(T) / 2^(|T| - 2)
287
+ assert order == 2, "Only pairwise supported for sparse path"
288
+
289
+ if not mobius_dict:
290
+ return []
291
+
292
+ scores: Dict[Tuple[int, ...], float] = {}
293
+
294
+ for T, mval in mobius_dict.items():
295
+ T = tuple(sorted(T))
296
+ k = len(T)
297
+ if k < order:
298
+ continue
299
+ # Weight: 1 / 2^(|T| - |S|) = 1 / 2^(k - 2)
300
+ weight = 1.0 / (2 ** (k - order))
301
+ for subset in combinations(T, order):
302
+ scores[subset] = scores.get(subset, 0.0) + weight * float(np.real(mval))
303
+
304
+ return _top_k(scores, top_k)
305
+ else:
306
+ # FULL PATH: Use traditional conversion for higher orders
307
+ bii = mobius_to_banzhaf_ii(mobius_dict)
308
+ filtered = _filter_order(bii, order=order)
309
+ return _top_k(filtered, top_k)
310
+
311
+ def mobius_to_banzhaf(mobius_dict: Dict[Tuple[int, ...], complex]) -> Dict[Tuple[int, ...], float]:
312
+ """
313
+ Convert Mobius coefficients to singleton Banzhaf values.
314
+
315
+ Optimized: Only computes up to order=1 (singletons) to avoid exponential blow-up.
316
+ """
317
+ # Only compute singletons - no need for higher-order powersets
318
+ bii = mobius_to_banzhaf_ii(mobius_dict, max_order=1)
319
+ return _filter_order(bii, order=1)
320
+
321
+ def mobius_to_influence(mobius_dict: Dict[Tuple[int, ...], complex]) -> Dict[Tuple[int, ...], float]:
322
+ """
323
+ Convert Mobius coefficients to singleton Influence values.
324
+
325
+ Optimized: Only computes up to order=1 (singletons) to avoid exponential blow-up.
326
+ """
327
+ # Only compute singletons - no need for higher-order powersets
328
+ iii = mobius_to_influence_ii(mobius_dict, max_order=1)
329
+ return _filter_order(iii, order=1)
330
+
331
+ def influence_interactions(
332
+ mobius_dict: Dict[Tuple[int, ...], complex],
333
+ order: int = 2,
334
+ top_k: int = 10,
335
+ ) -> List[Tuple[Tuple[int, ...], float]]:
336
+ """
337
+ Extract the top-k Influence interaction indices of a given order.
338
+
339
+ For order=2 (pairwise), uses sparse superset aggregation to avoid exponential blow-up.
340
+ For order>2, falls back to full conversion (may be expensive).
341
+
342
+ Formula for pairwise (|S|=2): β_banzhaf(S) = Σ_{T ⊇ S} m(T) / 2^(|T| - |S|)
343
+ """
344
+ # FULL PATH: Use traditional conversion for higher orders
345
+ iii = mobius_to_influence_ii(mobius_dict)
346
+ filtered = _filter_order(iii, order=order)
347
+ return _top_k(filtered, top_k)
348
+
349
+ def convert_to_heatmap_data(
350
+ attrs: Sequence[AttributionResult],
351
+ feature_order: Sequence[str] | None = None,
352
+ ) -> Dict[str, Sequence]:
353
+ """
354
+ Convert pairwise attribution results into a token × token interaction matrix.
355
+
356
+ Parameters
357
+ ----------
358
+ attrs:
359
+ Sequence of attribution entries where ``feature`` is either a single token
360
+ (diagonal contribution) or a tuple of tokens describing an interaction.
361
+ feature_order:
362
+ Optional list specifying the axis ordering. When omitted, tokens are added
363
+ according to first appearance in ``attrs``.
364
+ """
365
+ if not attrs:
366
+ # Safe default when there are no interactions
367
+ return {
368
+ "matrix": [],
369
+ "features": list(feature_order) if feature_order else [],
370
+ "methods": [],
371
+ "orders": [],
372
+ "min": 0.0,
373
+ "max": 0.0,
374
+ }
375
+
376
+ if feature_order:
377
+ tokens = list(feature_order)
378
+ else:
379
+ tokens = []
380
+ for attr in attrs:
381
+ feats = attr.feature if isinstance(attr.feature, (tuple, list)) else (attr.feature,)
382
+ for token in feats:
383
+ if token not in tokens:
384
+ tokens.append(token)
385
+
386
+ size = len(tokens)
387
+ if size == 0:
388
+ return {"matrix": [], "features": [], "methods": []}
389
+
390
+ index = {token: idx for idx, token in enumerate(tokens)}
391
+ matrix = [[0.0 for _ in range(size)] for _ in range(size)]
392
+ methods: List[str] = []
393
+ orders: List[int] = []
394
+
395
+ for attr in attrs:
396
+ feats = attr.feature if isinstance(attr.feature, (tuple, list)) else (attr.feature,)
397
+ if not feats:
398
+ continue
399
+ methods.append(attr.method)
400
+ orders.append(attr.interaction_order)
401
+
402
+ if len(feats) == 1:
403
+ token = feats[0]
404
+ if token in index:
405
+ idx = index[token]
406
+ matrix[idx][idx] = float(attr.value)
407
+ continue
408
+
409
+ if len(feats) > 2:
410
+ # For higher-order interactions, distribute the value across all ordered pairs.
411
+ iterator = [
412
+ (index[a], index[b])
413
+ for i, a in enumerate(feats)
414
+ for j, b in enumerate(feats)
415
+ if i != j and a in index and b in index
416
+ ]
417
+ else:
418
+ a, b = feats
419
+ if a not in index or b not in index:
420
+ continue
421
+ iterator = [(index[a], index[b]), (index[b], index[a])]
422
+
423
+ for i, j in iterator:
424
+ matrix[i][j] = float(attr.value)
425
+
426
+ flat_values = [value for row in matrix for value in row]
427
+ if not flat_values:
428
+ flat_values = [0.0]
429
+
430
+ return {
431
+ "matrix": matrix,
432
+ "features": tokens,
433
+ "methods": methods,
434
+ "orders": orders,
435
+ "min": float(min(flat_values)),
436
+ "max": float(max(flat_values)),
437
+ }
438
+
audioop.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # audioop stub for Python 3.13+ compatibility.
2
+ # Python 3.13 removed the built-in audioop module.
3
+ # pydub imports audioop but only calls it for audio operations (which this app never uses).
4
+ # This empty stub prevents the ImportError at startup.
5
+
6
+ def add(*a, **k): ...
7
+ def adpcm2lin(*a, **k): ...
8
+ def alaw2lin(*a, **k): ...
9
+ def avg(*a, **k): return 0
10
+ def avgpp(*a, **k): return 0
11
+ def bias(*a, **k): ...
12
+ def byteswap(*a, **k): ...
13
+ def cross(*a, **k): return 0
14
+ def findfactor(*a, **k): return 0.0
15
+ def findfit(*a, **k): return (0, 0.0)
16
+ def findmax(*a, **k): return 0
17
+ def getsample(*a, **k): return 0
18
+ def lin2adpcm(*a, **k): return (b"", 0)
19
+ def lin2alaw(*a, **k): return b""
20
+ def lin2lin(*a, **k): return b""
21
+ def lin2ulaw(*a, **k): return b""
22
+ def max(*a, **k): return 0
23
+ def maxpp(*a, **k): return 0
24
+ def minmax(*a, **k): return (0, 0)
25
+ def mul(*a, **k): return b""
26
+ def ratecv(*a, **k): return (b"", (0, 0))
27
+ def reverse(*a, **k): return b""
28
+ def rms(*a, **k): return 0
29
+ def tostereo(*a, **k): return b""
30
+ def tomono(*a, **k): return b""
31
+ def ulaw2lin(*a, **k): return b""
datasets/BBQ_disamb.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f0155987c1d7c2c396025e7288a7b4b299d7095f1f671fd982fa3f8af99680f1
3
+ size 4244
datasets/BarExam_qa.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8c81699f93bdccee10a45839297d509cfea95cda6961ac2575017fa03902e66d
3
+ size 14377
datasets/CNN_dailymail.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:211e6e255435474cbb1725327bde6e2d61f56f8aa4e4738fadf2e378e5dd8d96
3
+ size 54840
datasets/bbh_causal_judgement.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3363c531c6eed1ed5661671f98c95e44b0876927d86228409169cbf4c9638767
3
+ size 14737
datasets/bbh_snarks.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e06480910f969da34c642c4d581dbbbf1c3b922f9529a243b3f56293b19824e5
3
+ size 1871
datasets/drop.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1aabf12072359977d9bb42a445d79e116e60b127a67e088346e1fef0fa89e6db
3
+ size 18030
datasets/eSNLI.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c57e79a35ffabf0f857be75b137d12174fee46661da71017df79028fd7be41b
3
+ size 3109
datasets/fever.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6059f85f2d1a2ebe2cdeb9786a237f942fa159124376024b271c6bb55a7e46c9
3
+ size 1583
datasets/hotpot_qa.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a746b3324975632a5d94c703e203fc19bd8ce6b3cc163050d29488c6e662d127
3
+ size 66525
datasets/medical_qa.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d761128bf4e3c1641c8f6976dead0c1d78059ea46e6c3c261b75d53c2c3567cb
3
+ size 8908
loader/__init__.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # loader/__init__.py
2
+
3
+ from .models import (
4
+ BaseModel,
5
+ ModelInfo,
6
+ InferenceResult,
7
+ get_model,
8
+ )
9
+ from .data import (
10
+ list_datasets,
11
+ get_examples,
12
+ get_example_by_id,
13
+ )
14
+ from .perplexity import (
15
+ score_continuation,
16
+ score_reference_autoregressive_hf,
17
+ score_reference_autoregressive_vllm,
18
+ )
19
+ from .utils import device_info, set_seed
20
+
21
+ # --- Optional vLLM hook (only works inside the vLLM container) ---
22
+ import traceback as _traceback
23
+
24
+ _VLLM_IMPORT_ERR = None
25
+
26
+ try:
27
+ from .models_vllm import VLLMModel
28
+ except Exception as exc: # capture and expose real reason
29
+ VLLMModel = None
30
+ _VLLM_IMPORT_ERR = _traceback.format_exc()
31
+
32
+ def get_model_vllm(size: str = "small") -> BaseModel:
33
+ msg = (
34
+ "vLLM is not available in this environment. "
35
+ "Run inside the vllm_meng.sif container to use get_model_vllm()."
36
+ )
37
+ if _VLLM_IMPORT_ERR:
38
+ msg += f"\nUnderlying import error:\n{_VLLM_IMPORT_ERR}"
39
+ raise RuntimeError(msg) # surface actual import failure
40
+
41
+ else:
42
+
43
+ def get_model_vllm(size: str = "small") -> BaseModel:
44
+ return VLLMModel(size)
45
+
46
+ # --- Optional vLLM multimodal hook ---
47
+ try:
48
+ from .models_mm_vllm import VLLMMultimodalModel, list_mm_model_keys, get_mm_model_info
49
+
50
+ def get_model_mm_vllm(key: str = "vl_small") -> BaseModel:
51
+ return VLLMMultimodalModel(key)
52
+
53
+ except Exception:
54
+ VLLMMultimodalModel = None
55
+
56
+ def get_model_mm_vllm(key: str = "vl_small") -> BaseModel:
57
+ raise RuntimeError(
58
+ "Multimodal vLLM is not available in this environment. "
59
+ "Run inside the vLLM container to use get_model_mm_vllm()."
60
+ )
61
+
62
+ def list_mm_model_keys() -> list[str]:
63
+ return []
64
+
65
+ def get_mm_model_info(_key: str):
66
+ return None
67
+
68
+
69
+ def debug_vllm_status():
70
+ """
71
+ Print basic diagnostics about vLLM availability/import state.
72
+ """
73
+ import sys, os
74
+
75
+ print("cwd:", os.getcwd())
76
+ print("sys.path[0:3]:", sys.path[:3])
77
+ try:
78
+ import vllm # type: ignore
79
+
80
+ print("vllm import: OK", getattr(vllm, '__version__', 'unknown'))
81
+ except Exception as exc: # pragma: no cover - diagnostic helper
82
+ print("vllm import: FAILED", exc)
83
+ if _VLLM_IMPORT_ERR:
84
+ print("Stored VLLM import error trace:\n", _VLLM_IMPORT_ERR)
loader/data.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # loader/data.py
2
+ from typing import List, Dict
3
+ from pathlib import Path
4
+ import csv
5
+
6
+ # -------------------------------------------------------------------
7
+ # 1. Mapping from dataset key → CSV filename
8
+ # -------------------------------------------------------------------
9
+ _DATASET_FILES: Dict[str, str] = {
10
+ "bar_exam": "BarExam_qa.csv",
11
+ "causal_judgment": "bbh_causal_judgement.csv",
12
+ "snarks": "bbh_snarks.csv",
13
+ "bbq_disamb": "BBQ_disamb.csv",
14
+ "cnn_dailymail": "CNN_dailymail.csv",
15
+ "drop": "drop.csv",
16
+ "esnli": "eSNLI.csv",
17
+ "fever": "fever.csv",
18
+ "hotpot_qa": "hotpot_qa.csv",
19
+ "medical_qa": "medical_qa.csv",
20
+ }
21
+
22
+ # -------------------------------------------------------------------
23
+ # 1b. Human-readable display names for UI
24
+ # -------------------------------------------------------------------
25
+ _DATASET_DISPLAY_NAMES: Dict[str, str] = {
26
+ "bar_exam": "Bar Exam Questions",
27
+ "causal_judgment": "Causal Judgment",
28
+ "snarks": "Snarks",
29
+ "bbq_disamb": "BBQ Disambiguation",
30
+ "cnn_dailymail": "CNN / DailyMail Summaries",
31
+ "drop": "DROP Reading Comprehension",
32
+ "esnli": "e-SNLI Natural Language Inference",
33
+ "fever": "FEVER Fact Checking",
34
+ "hotpot_qa": "HotpotQA Multi-hop Questions",
35
+ "medical_qa": "Medical Questions",
36
+ }
37
+
38
+ # -------------------------------------------------------------------
39
+ # 2. Where the CSVs live (loader/../datasets/)
40
+ # -------------------------------------------------------------------
41
+ def _datasets_dir() -> Path:
42
+ return (Path(__file__).resolve().parent.parent / "datasets").resolve()
43
+
44
+ # -------------------------------------------------------------------
45
+ # 3. Pick the first non-empty column among several candidates
46
+ # -------------------------------------------------------------------
47
+ def _pick_first_nonempty(raw: Dict[str, str], candidates: List[str]) -> str:
48
+ for c in candidates:
49
+ val = raw.get(c)
50
+ if val is not None and str(val).strip() != "":
51
+ return str(val)
52
+ return ""
53
+
54
+ # -------------------------------------------------------------------
55
+ # 4. Load a single CSV file and normalize it to our schema
56
+ # -------------------------------------------------------------------
57
+ def _load_one_dataset(name: str, filename: str) -> List[Dict[str, str]]:
58
+ """
59
+ Reads a CSV file and converts each row to our standard format:
60
+
61
+ {
62
+ "id": "example_1",
63
+ "context": "...",
64
+ "prompt": "...",
65
+ "answer": "..." # optional
66
+ }
67
+
68
+ Only the first 10 rows are kept.
69
+ """
70
+ path = _datasets_dir() / filename
71
+ rows: List[Dict[str, str]] = []
72
+
73
+ # errors="replace" avoids Unicode crashes for imperfect CSVs
74
+ try:
75
+ with path.open("r", encoding="utf-8", errors="replace", newline="") as f:
76
+ reader = csv.DictReader(f)
77
+
78
+ for i, raw in enumerate(reader, start=1):
79
+
80
+ ex_id = raw.get("id") or raw.get("example_id") \
81
+ or raw.get("uid") or f"example_{i}"
82
+
83
+ context = _pick_first_nonempty(raw, [
84
+ "Context", "context",
85
+ "passage", "article", "story", "premise",
86
+ "paragraph", "document", "sentence1", "sent1", "background",
87
+ ])
88
+
89
+ prompt = _pick_first_nonempty(raw, [
90
+ "Prompt", "prompt",
91
+ "question", "input", "query",
92
+ "sentence2", "sent2", "hypothesis",
93
+ "qa_question", "title",
94
+ ])
95
+
96
+ answer = _pick_first_nonempty(raw, [
97
+ "Answer", "answer",
98
+ "target", "gold", "label", "output", "reference",
99
+ "highlights",
100
+ ])
101
+
102
+ ex = {
103
+ "id": str(ex_id),
104
+ "context": context,
105
+ "prompt": prompt,
106
+ }
107
+ if answer:
108
+ ex["answer"] = answer
109
+
110
+ rows.append(ex)
111
+ except FileNotFoundError:
112
+ return []
113
+ except Exception:
114
+ # Keep import resilient in constrained environments (e.g., Spaces).
115
+ return []
116
+
117
+ return rows[:10] # keep exactly 10 examples
118
+
119
+ # -------------------------------------------------------------------
120
+ # 5. Load all datasets ONCE when the module is imported
121
+ # -------------------------------------------------------------------
122
+ def _load_all_datasets() -> Dict[str, List[Dict[str, str]]]:
123
+ return {
124
+ name: _load_one_dataset(name, filename)
125
+ for name, filename in _DATASET_FILES.items()
126
+ }
127
+
128
+ _DATA: Dict[str, List[Dict[str, str]]] = _load_all_datasets() # ← cached
129
+
130
+ # -------------------------------------------------------------------
131
+ # 6. Public Functions — these are used by the app
132
+ # -------------------------------------------------------------------
133
+ def list_datasets() -> List[str]:
134
+ """Return all dataset names, sorted alphabetically."""
135
+ return sorted(_DATA.keys())
136
+
137
+
138
+ def get_dataset_display_name(dataset: str) -> str:
139
+ """Return human-readable display name for a dataset."""
140
+ return _DATASET_DISPLAY_NAMES.get(dataset, dataset)
141
+
142
+
143
+ def get_dataset_key_from_display_name(display_name: str) -> str:
144
+ """Convert display name back to internal key."""
145
+ # Create reverse mapping
146
+ for key, name in _DATASET_DISPLAY_NAMES.items():
147
+ if name == display_name:
148
+ return key
149
+ # If not found, assume it's already a key
150
+ return display_name
151
+
152
+
153
+ def list_datasets_with_display_names() -> List[tuple[str, str]]:
154
+ """Return list of (key, display_name) tuples, sorted by display name."""
155
+ pairs = [(key, _DATASET_DISPLAY_NAMES.get(key, key)) for key in _DATA.keys()]
156
+ return sorted(pairs, key=lambda x: x[1])
157
+
158
+
159
+ def list_dataset_display_names() -> List[str]:
160
+ """Return list of display names only, sorted alphabetically."""
161
+ names = [_DATASET_DISPLAY_NAMES.get(key, key) for key in _DATA.keys()]
162
+ return sorted(names)
163
+
164
+
165
+ def get_examples(dataset: str, n: int = 10) -> List[Dict[str, str]]:
166
+ """Return up to n examples for a dataset."""
167
+ if dataset not in _DATA:
168
+ raise KeyError(f"Unknown dataset: {dataset}")
169
+ return _DATA[dataset][:n]
170
+
171
+
172
+ def get_example_by_id(dataset: str, ex_id: str) -> Dict[str, str]:
173
+ """Return a single example whose ID matches ex_id."""
174
+ if dataset not in _DATA:
175
+ raise KeyError(f"Unknown dataset: {dataset}")
176
+ for ex in _DATA[dataset]:
177
+ if ex["id"] == ex_id:
178
+ return ex
179
+ raise KeyError(f"Example id '{ex_id}' not found in dataset '{dataset}'")
loader/models.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional
3
+
4
+ @dataclass
5
+ class ModelInfo:
6
+ size: str
7
+ name: str
8
+ max_model_len: int
9
+ is_chat: bool
10
+ is_multimodal: bool = False
11
+ image_placeholder: Optional[str] = None
12
+ mm_data_key: Optional[str] = None
13
+
14
+ @dataclass
15
+ class InferenceResult:
16
+ prompt: str
17
+ text: str
18
+ token_ids: List[int] = field(default_factory=list)
19
+ # one scalar logprob per generated token (or None if not requested)
20
+ logprobs: Optional[List[float]] = None
21
+
22
+ class BaseModel:
23
+ def info(self) -> ModelInfo:
24
+ raise NotImplementedError
25
+
26
+ def infer(
27
+ self,
28
+ prompts: List[str],
29
+ *,
30
+ max_tokens: int = 256,
31
+ temperature: float = 0.7,
32
+ top_p: float = 0.95,
33
+ logprobs: Optional[int] = None,
34
+ stop: Optional[List[str]] = None,
35
+ ) -> List[InferenceResult]:
36
+ raise NotImplementedError
37
+
38
+ # --- Minimal working dummy model so imports/tests succeed ---
39
+ class _DummyModel(BaseModel):
40
+ def __init__(self, size: str):
41
+ self._info = ModelInfo(
42
+ size=size,
43
+ name=f"dummy-{size}",
44
+ max_model_len=4096,
45
+ is_chat=False,
46
+ )
47
+
48
+ def info(self) -> ModelInfo:
49
+ return self._info
50
+
51
+ def infer(self, prompts: List[str], **kwargs) -> List[InferenceResult]:
52
+ outs: List[InferenceResult] = []
53
+ for p in prompts:
54
+ outs.append(InferenceResult(
55
+ prompt=p,
56
+ text=f"[dummy completion for]: {p}",
57
+ token_ids=[0, 1, 2],
58
+ logprobs=None
59
+ ))
60
+ return outs
61
+
62
+ def get_model(size: str) -> BaseModel:
63
+ if size not in {"small", "medium", "large"}:
64
+ raise ValueError("size must be one of {'small','medium','large'}")
65
+ return _DummyModel(size)
loader/models_mm_hf.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # loader/models_mm_hf.py
2
+ """
3
+ HuggingFace Transformers wrapper for multimodal VLMs (e.g. LLaVA).
4
+
5
+ Provides the same interface as VLLMMultimodalModel so the attribution
6
+ pipeline in mm_game.py can use it without changes. Slower than vLLM
7
+ (no continuous batching, no PagedAttention) but works on any GPU without
8
+ specific vLLM version requirements.
9
+ """
10
+
11
+ import math
12
+ import os
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, List, Optional, Sequence
15
+
16
+ import torch
17
+ from PIL import Image
18
+
19
+ from .models import BaseModel, InferenceResult, ModelInfo
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Fake vLLM-compatible output objects (just enough for _score_from_prompt_logprobs)
24
+ # ---------------------------------------------------------------------------
25
+
26
+ @dataclass
27
+ class _FakeCompletionOutput:
28
+ text: str
29
+ token_ids: List[int]
30
+
31
+
32
+ @dataclass
33
+ class _FakeRequestOutput:
34
+ outputs: List[_FakeCompletionOutput]
35
+ prompt_logprobs: Optional[List[Optional[Dict[int, float]]]]
36
+
37
+
38
+ class _FakeSamplingParams:
39
+ """Mirrors vllm.SamplingParams fields used by mm_game.py."""
40
+ def __init__(self, **kwargs):
41
+ self.max_tokens = kwargs.get("max_tokens", 256)
42
+ self.temperature = kwargs.get("temperature", 0.0)
43
+ self.top_p = kwargs.get("top_p", 1.0)
44
+ self.logprobs = kwargs.get("logprobs", 0)
45
+ self.prompt_logprobs = kwargs.get("prompt_logprobs", None)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # HF wrapper that mimics vLLM LLM interface
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class _HFLLMShim:
53
+ """
54
+ Drop-in replacement for vllm.LLM that uses HuggingFace transformers.
55
+
56
+ Only implements the methods that mm_game.py actually calls:
57
+ - get_tokenizer()
58
+ - generate(requests, sampling_params) where requests are dicts
59
+ with "prompt" and "multi_modal_data" keys
60
+ """
61
+
62
+ def __init__(self, model_name: str, device: str = "cuda"):
63
+ from transformers import AutoProcessor, LlavaNextForConditionalGeneration, LlavaForConditionalGeneration
64
+
65
+ self._model_name = model_name
66
+ self._device = device
67
+
68
+ print(f" [HF] Loading processor for {model_name} ...")
69
+ self._processor = AutoProcessor.from_pretrained(model_name)
70
+
71
+ print(f" [HF] Loading model {model_name} (torch.float16) ...")
72
+ # Try LlavaNext first, fall back to Llava
73
+ try:
74
+ self._model = LlavaNextForConditionalGeneration.from_pretrained(
75
+ model_name, torch_dtype=torch.float16, device_map=device,
76
+ )
77
+ except Exception:
78
+ self._model = LlavaForConditionalGeneration.from_pretrained(
79
+ model_name, torch_dtype=torch.float16, device_map=device,
80
+ )
81
+ self._model.eval()
82
+ print(f" [HF] Model loaded on {device}.")
83
+
84
+ def get_tokenizer(self):
85
+ """Return the tokenizer (same interface as vllm.LLM.get_tokenizer)."""
86
+ return self._processor.tokenizer
87
+
88
+ def generate(
89
+ self,
90
+ requests: List[Dict[str, Any]],
91
+ sampling_params: Any,
92
+ ) -> List[_FakeRequestOutput]:
93
+ """
94
+ Mimic vllm.LLM.generate() for multimodal requests.
95
+
96
+ Each request is {"prompt": str, "multi_modal_data": {"image": PIL.Image}}.
97
+ Returns list of _FakeRequestOutput with .prompt_logprobs and .outputs.
98
+ """
99
+ results = []
100
+ for req in requests:
101
+ prompt = req["prompt"]
102
+ mm_data = req.get("multi_modal_data", {})
103
+
104
+ # Extract image
105
+ raw_image = mm_data.get("image")
106
+ if isinstance(raw_image, list):
107
+ raw_image = raw_image[0] if raw_image else None
108
+
109
+ want_prompt_logprobs = getattr(sampling_params, "prompt_logprobs", None)
110
+ max_new = getattr(sampling_params, "max_tokens", 256)
111
+
112
+ if want_prompt_logprobs:
113
+ result = self._score_prompt_logprobs(prompt, raw_image, max_new)
114
+ else:
115
+ result = self._generate_text(prompt, raw_image, max_new, sampling_params)
116
+
117
+ results.append(result)
118
+ return results
119
+
120
+ def _process_inputs(self, prompt: str, image: Optional[Image.Image]):
121
+ """Prepare model inputs from prompt + image."""
122
+ if image is not None:
123
+ inputs = self._processor(text=prompt, images=image, return_tensors="pt")
124
+ else:
125
+ inputs = self._processor(text=prompt, return_tensors="pt")
126
+ return {k: v.to(self._device) for k, v in inputs.items()}
127
+
128
+ def _score_prompt_logprobs(
129
+ self, prompt: str, image: Optional[Image.Image], max_new: int
130
+ ) -> _FakeRequestOutput:
131
+ """Compute per-token log-probabilities of the prompt (for log-prob attribution)."""
132
+ inputs = self._process_inputs(prompt, image)
133
+ input_ids = inputs["input_ids"]
134
+ seq_len = input_ids.shape[1]
135
+
136
+ with torch.inference_mode():
137
+ outputs = self._model(**inputs)
138
+ # logits shape: (1, seq_len, vocab_size)
139
+ logits = outputs.logits[0] # (seq_len, vocab_size)
140
+
141
+ log_probs = torch.log_softmax(logits, dim=-1) # (seq_len, vocab_size)
142
+
143
+ # Build prompt_logprobs in vLLM format:
144
+ # prompt_logprobs[i] = {token_id: logprob} for position i
145
+ # Position i's logprob comes from logits at position i-1 (autoregressive)
146
+ prompt_logprobs: List[Optional[Dict[int, float]]] = [None] # position 0 has no predecessor
147
+ flat_ids = input_ids[0].tolist()
148
+
149
+ for i in range(1, seq_len):
150
+ tid = flat_ids[i]
151
+ lp = log_probs[i - 1, tid].item()
152
+ # Include top-5 for the position (matching vLLM's logprobs=5)
153
+ top_vals, top_ids = torch.topk(log_probs[i - 1], k=min(5, log_probs.shape[-1]))
154
+ pos_dict = {int(top_ids[j]): float(top_vals[j]) for j in range(len(top_ids))}
155
+ if tid not in pos_dict:
156
+ pos_dict[tid] = float(lp)
157
+ prompt_logprobs.append(pos_dict)
158
+
159
+ return _FakeRequestOutput(
160
+ outputs=[_FakeCompletionOutput(text="", token_ids=[])],
161
+ prompt_logprobs=prompt_logprobs,
162
+ )
163
+
164
+ def _generate_text(
165
+ self,
166
+ prompt: str,
167
+ image: Optional[Image.Image],
168
+ max_new: int,
169
+ sampling_params: Any,
170
+ ) -> _FakeRequestOutput:
171
+ """Generate text (for generation attribution)."""
172
+ inputs = self._process_inputs(prompt, image)
173
+ input_len = inputs["input_ids"].shape[1]
174
+
175
+ temperature = getattr(sampling_params, "temperature", 0.0)
176
+ do_sample = temperature > 0
177
+
178
+ with torch.inference_mode():
179
+ output_ids = self._model.generate(
180
+ **inputs,
181
+ max_new_tokens=max_new,
182
+ do_sample=do_sample,
183
+ temperature=temperature if do_sample else None,
184
+ )
185
+
186
+ new_ids = output_ids[0, input_len:].tolist()
187
+ text = self._processor.tokenizer.decode(new_ids, skip_special_tokens=True)
188
+
189
+ return _FakeRequestOutput(
190
+ outputs=[_FakeCompletionOutput(text=text, token_ids=new_ids)],
191
+ prompt_logprobs=None,
192
+ )
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Public model class
197
+ # ---------------------------------------------------------------------------
198
+
199
+ class HFMultimodalModel(BaseModel):
200
+ """
201
+ HuggingFace Transformers multimodal model.
202
+
203
+ Same interface as VLLMMultimodalModel but uses transformers instead of vLLM.
204
+ The .llm attribute is a _HFLLMShim that mimics vLLM's LLM API.
205
+ """
206
+
207
+ def __init__(self, model_name: str, device: str = "cuda"):
208
+ user = os.environ.get("USER", "user")
209
+ cache_dir = f"/tmp/{user}/hf_cache"
210
+ os.makedirs(cache_dir, exist_ok=True)
211
+ os.environ["HF_HOME"] = cache_dir
212
+ os.environ["TRANSFORMERS_CACHE"] = cache_dir
213
+ os.environ["HUGGINGFACE_HUB_CACHE"] = cache_dir
214
+
215
+ self._info = ModelInfo(
216
+ size="hf_" + model_name.split("/")[-1],
217
+ name=model_name,
218
+ max_model_len=4096,
219
+ is_chat=True,
220
+ is_multimodal=True,
221
+ image_placeholder="<image>",
222
+ mm_data_key="image",
223
+ )
224
+
225
+ self.llm = _HFLLMShim(model_name, device)
226
+
227
+ @property
228
+ def info(self) -> ModelInfo:
229
+ return self._info
230
+
231
+ def infer(
232
+ self,
233
+ prompts: List[str],
234
+ max_new_tokens: int = 256,
235
+ temperature: float = 0.0,
236
+ top_p: float = 1.0,
237
+ logprobs: int = 0,
238
+ ) -> List[InferenceResult]:
239
+ """Generate text for a list of prompts (no image — text-only generation)."""
240
+ sp = _FakeSamplingParams(
241
+ max_tokens=max_new_tokens,
242
+ temperature=temperature,
243
+ top_p=top_p,
244
+ logprobs=logprobs,
245
+ )
246
+ results = []
247
+ for prompt in prompts:
248
+ req = {"prompt": prompt, "multi_modal_data": {}}
249
+ out = self.llm.generate([req], sp)[0]
250
+ seq = out.outputs[0]
251
+ results.append(InferenceResult(prompt=prompt, text=seq.text, token_ids=seq.token_ids))
252
+ return results
loader/models_mm_vllm.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # loader/models_mm_vllm.py
2
+
3
+ import os
4
+ from typing import List, Optional
5
+
6
+ from vllm import LLM, SamplingParams
7
+
8
+ from .models import BaseModel, InferenceResult, ModelInfo
9
+
10
+ # ---------------------------------------------------------------------
11
+ # Multimodal model registry (vLLM-compatible VLMs only)
12
+ # ---------------------------------------------------------------------
13
+ _MM_MODEL_CONFIGS = {
14
+ "vl_small": ModelInfo(
15
+ size="vl_small",
16
+ name="Qwen/Qwen2-VL-2B-Instruct",
17
+ max_model_len=4096,
18
+ is_chat=True,
19
+ is_multimodal=True,
20
+ image_placeholder="<|image_pad|>",
21
+ mm_data_key="image",
22
+ ),
23
+ "vl_large": ModelInfo(
24
+ size="vl_large",
25
+ name="Qwen/Qwen2-VL-7B-Instruct",
26
+ max_model_len=4096,
27
+ is_chat=True,
28
+ is_multimodal=True,
29
+ image_placeholder="<|image_pad|>",
30
+ mm_data_key="image",
31
+ ),
32
+ "llavamed": ModelInfo(
33
+ size="llavamed",
34
+ name="llava-hf/llava-v1.6-mistral-7b-hf",
35
+ max_model_len=4096,
36
+ is_chat=True,
37
+ is_multimodal=True,
38
+ image_placeholder="<image>",
39
+ mm_data_key="image",
40
+ ),
41
+ }
42
+
43
+
44
+ def list_mm_model_keys() -> List[str]:
45
+ return list(_MM_MODEL_CONFIGS.keys())
46
+
47
+
48
+ def get_mm_model_info(key: str) -> Optional[ModelInfo]:
49
+ return _MM_MODEL_CONFIGS.get(key)
50
+
51
+
52
+ class VLLMMultimodalModel(BaseModel):
53
+ def __init__(self, key: str = "vl_small") -> None:
54
+ if key not in _MM_MODEL_CONFIGS:
55
+ raise ValueError(f"Unknown multimodal model key: {key}")
56
+
57
+ info = _MM_MODEL_CONFIGS[key]
58
+ gpu_utilization = _resolve_gpu_memory_utilization(key)
59
+ max_model_len = _resolve_max_model_len(key, info.max_model_len)
60
+ self._info = ModelInfo(
61
+ size=info.size,
62
+ name=info.name,
63
+ max_model_len=max_model_len,
64
+ is_chat=info.is_chat,
65
+ is_multimodal=True,
66
+ image_placeholder=info.image_placeholder,
67
+ mm_data_key=info.mm_data_key,
68
+ )
69
+
70
+ user = os.environ.get("USER", "user")
71
+ cache_dir = f"/tmp/{user}/hf_cache"
72
+ os.makedirs(cache_dir, exist_ok=True)
73
+ os.environ["HF_HOME"] = cache_dir
74
+ os.environ["TRANSFORMERS_CACHE"] = cache_dir
75
+ os.environ["HUGGINGFACE_HUB_CACHE"] = cache_dir
76
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
77
+ download_dir = cache_dir
78
+
79
+ self.llm = LLM(
80
+ model=info.name,
81
+ tensor_parallel_size=1,
82
+ download_dir=download_dir,
83
+ trust_remote_code=True,
84
+ disable_log_stats=True,
85
+ max_model_len=max_model_len,
86
+ gpu_memory_utilization=gpu_utilization,
87
+ )
88
+
89
+ @property
90
+ def info(self) -> ModelInfo:
91
+ return self._info
92
+
93
+ def infer(
94
+ self,
95
+ prompts: List[str],
96
+ max_new_tokens: int = 256,
97
+ temperature: float = 0.0,
98
+ top_p: float = 1.0,
99
+ logprobs: int = 0,
100
+ ) -> List[InferenceResult]:
101
+ sp = SamplingParams(
102
+ max_tokens=max_new_tokens,
103
+ temperature=temperature,
104
+ top_p=top_p,
105
+ logprobs=logprobs,
106
+ )
107
+
108
+ outs = self.llm.generate(prompts, sp)
109
+
110
+ results: List[InferenceResult] = []
111
+ for prompt, out in zip(prompts, outs):
112
+ seq = out.outputs[0]
113
+ text = seq.text
114
+ token_ids = list(seq.token_ids)
115
+ results.append(
116
+ InferenceResult(
117
+ prompt=prompt,
118
+ text=text,
119
+ token_ids=token_ids,
120
+ )
121
+ )
122
+
123
+ return results
124
+
125
+
126
+ def _parse_env_float(name: str) -> Optional[float]:
127
+ value = os.getenv(name)
128
+ if value is None or value.strip() == "":
129
+ return None
130
+ try:
131
+ return float(value)
132
+ except ValueError as exc:
133
+ raise ValueError(f"{name} must be a float, got {value!r}") from exc
134
+
135
+
136
+ def _parse_env_int(name: str) -> Optional[int]:
137
+ value = os.getenv(name)
138
+ if value is None or value.strip() == "":
139
+ return None
140
+ try:
141
+ return int(value)
142
+ except ValueError as exc:
143
+ raise ValueError(f"{name} must be an int, got {value!r}") from exc
144
+
145
+
146
+ def _resolve_gpu_memory_utilization(key: str) -> float:
147
+ key_upper = key.upper()
148
+ override = _parse_env_float(f"VLLM_GPU_MEMORY_UTILIZATION_{key_upper}")
149
+ if override is not None:
150
+ return override
151
+ override = _parse_env_float("VLLM_GPU_MEMORY_UTILIZATION")
152
+ if override is not None:
153
+ return override
154
+ return 0.6
155
+
156
+
157
+ def _resolve_max_model_len(key: str, default: int) -> int:
158
+ key_upper = key.upper()
159
+ override = _parse_env_int(f"VLLM_MAX_MODEL_LEN_{key_upper}")
160
+ if override is not None:
161
+ return override
162
+ override = _parse_env_int("VLLM_MAX_MODEL_LEN")
163
+ if override is not None:
164
+ return override
165
+ return default
loader/models_vllm.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # loader/models_vllm.py
2
+
3
+ import logging
4
+ import os
5
+ from typing import List, Optional
6
+
7
+ from vllm import LLM, SamplingParams
8
+
9
+ from .models import BaseModel, InferenceResult, ModelInfo
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # ---------------------------------------------------------------------
14
+ # Model registry
15
+ # ---------------------------------------------------------------------
16
+ _MODEL_CONFIGS = {
17
+ "small": ModelInfo(
18
+ size="small",
19
+ name="Qwen/Qwen3-4B-Instruct-2507",
20
+ max_model_len=8192, # we cap to 32k, no need for 262k
21
+ is_chat=True,
22
+ ),
23
+ "medium": ModelInfo(
24
+ size="medium",
25
+ name="mistralai/Mistral-7B-Instruct-v0.2",
26
+ max_model_len=8192,
27
+ is_chat=True,
28
+ ),
29
+ "large": ModelInfo(
30
+ size="large",
31
+ name="Qwen/Qwen3-30B-A3B-Instruct-2507",
32
+ max_model_len=2048, # reduced to fit KV cache within GPU memory
33
+ is_chat=True,
34
+ ),
35
+ }
36
+
37
+
38
+ class VLLMModel(BaseModel):
39
+ def __init__(self, size: str = "small") -> None:
40
+ if size not in _MODEL_CONFIGS:
41
+ raise ValueError(f"Unknown VLLM model size: {size}")
42
+
43
+ info = _MODEL_CONFIGS[size]
44
+ gpu_utilization = _resolve_gpu_memory_utilization(size)
45
+ max_model_len = _resolve_max_model_len(size, info.max_model_len)
46
+ max_num_seqs = _resolve_max_num_seqs(size)
47
+ max_batched_tokens = _resolve_max_batched_tokens(size)
48
+ self._info = ModelInfo(
49
+ size=info.size,
50
+ name=info.name,
51
+ max_model_len=max_model_len,
52
+ is_chat=info.is_chat,
53
+ )
54
+
55
+ # ---------------------------------------------------
56
+ # Use node-local /tmp for all HuggingFace / vLLM cache
57
+ # ---------------------------------------------------
58
+ user = os.environ.get("USER", "user")
59
+ cache_dir = f"/tmp/{user}/hf_cache"
60
+
61
+ # Make sure the directory exists
62
+ os.makedirs(cache_dir, exist_ok=True)
63
+
64
+ # Tell HF + transformers + vLLM to use this cache
65
+ os.environ["HF_HOME"] = cache_dir
66
+ os.environ["TRANSFORMERS_CACHE"] = cache_dir
67
+ os.environ["HUGGINGFACE_HUB_CACHE"] = cache_dir
68
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
69
+
70
+ # vLLM will also use this as download_dir
71
+ download_dir = cache_dir
72
+
73
+ # ----------------------------------
74
+ # Log GPU state & resolved config
75
+ # ----------------------------------
76
+ try:
77
+ import torch
78
+ if torch.cuda.is_available():
79
+ free, total = torch.cuda.mem_get_info(0)
80
+ logger.info(
81
+ "GPU memory: free=%.2f GiB total=%.2f GiB (before vLLM init)",
82
+ free / (1 << 30), total / (1 << 30),
83
+ )
84
+ except Exception:
85
+ pass
86
+
87
+ enforce_eager = os.environ.get("VLLM_ENFORCE_EAGER", "1") == "1"
88
+
89
+ logger.info(
90
+ "VLLMModel(%s): model=%s gpu_util=%.2f max_model_len=%d "
91
+ "enforce_eager=%s max_num_seqs=%s max_batched_tokens=%s",
92
+ size, info.name, gpu_utilization, max_model_len,
93
+ enforce_eager, max_num_seqs, max_batched_tokens,
94
+ )
95
+
96
+ # ----------------------------------
97
+ # Create the vLLM LLM instance
98
+ # ----------------------------------
99
+ self.llm = LLM(
100
+ model=info.name,
101
+ tensor_parallel_size=1,
102
+ download_dir=download_dir,
103
+ trust_remote_code=True,
104
+ disable_log_stats=True,
105
+ max_model_len=max_model_len,
106
+ gpu_memory_utilization=gpu_utilization,
107
+ max_num_seqs=max_num_seqs,
108
+ max_num_batched_tokens=max_batched_tokens,
109
+ enforce_eager=enforce_eager,
110
+ )
111
+
112
+ # For convenience, expose model metadata
113
+ @property
114
+ def info(self) -> ModelInfo:
115
+ return self._info
116
+
117
+ def infer(
118
+ self,
119
+ prompts: List[str],
120
+ max_new_tokens: int = 256,
121
+ temperature: float = 0.0,
122
+ top_p: float = 1.0,
123
+ logprobs: int = 0,
124
+ ) -> List[InferenceResult]:
125
+ sp = SamplingParams(
126
+ max_tokens=max_new_tokens,
127
+ temperature=temperature,
128
+ top_p=top_p,
129
+ logprobs=logprobs,
130
+ )
131
+
132
+ outs = self.llm.generate(prompts, sp)
133
+
134
+ results: List[InferenceResult] = []
135
+ for prompt, out in zip(prompts, outs):
136
+ seq = out.outputs[0]
137
+ text = seq.text
138
+
139
+ # vLLM gives a list of int token IDs
140
+ token_ids = list(seq.token_ids)
141
+
142
+ # You *can* still compute token_logprobs here if you want,
143
+ # but don't pass them into InferenceResult yet.
144
+ # (You can add this field to the dataclass later if needed.)
145
+
146
+ results.append(
147
+ InferenceResult(
148
+ prompt=prompt,
149
+ text=text,
150
+ token_ids=token_ids,
151
+ )
152
+ )
153
+
154
+ return results
155
+
156
+
157
+ def _parse_env_float(name: str) -> Optional[float]:
158
+ value = os.getenv(name)
159
+ if value is None or value.strip() == "":
160
+ return None
161
+ try:
162
+ return float(value)
163
+ except ValueError as exc:
164
+ raise ValueError(f"{name} must be a float, got {value!r}") from exc
165
+
166
+
167
+ def _parse_env_int(name: str) -> Optional[int]:
168
+ value = os.getenv(name)
169
+ if value is None or value.strip() == "":
170
+ return None
171
+ try:
172
+ return int(value)
173
+ except ValueError as exc:
174
+ raise ValueError(f"{name} must be an int, got {value!r}") from exc
175
+
176
+
177
+ def _resolve_max_num_seqs(size: str) -> Optional[int]:
178
+ size_key = size.upper()
179
+ override = _parse_env_int(f"VLLM_MAX_NUM_SEQS_{size_key}")
180
+ if override is not None:
181
+ return override
182
+ return _parse_env_int("VLLM_MAX_NUM_SEQS")
183
+
184
+
185
+ def _resolve_max_batched_tokens(size: str) -> Optional[int]:
186
+ size_key = size.upper()
187
+ override = _parse_env_int(f"VLLM_MAX_BATCHED_TOKENS_{size_key}")
188
+ if override is not None:
189
+ return override
190
+ return _parse_env_int("VLLM_MAX_BATCHED_TOKENS")
191
+
192
+
193
+ def _resolve_gpu_memory_utilization(size: str) -> float:
194
+ size_key = size.upper()
195
+ # Per-size override: VLLM_GPU_MEMORY_UTILIZATION_LARGE, etc.
196
+ override = _parse_env_float(f"VLLM_GPU_MEMORY_UTILIZATION_{size_key}")
197
+ if override is not None:
198
+ return override
199
+ # Global override: VLLM_GPU_MEMORY_UTILIZATION or short alias VLLM_GPU_UTIL
200
+ override = _parse_env_float("VLLM_GPU_MEMORY_UTILIZATION")
201
+ if override is not None:
202
+ return override
203
+ override = _parse_env_float("VLLM_GPU_UTIL")
204
+ if override is not None:
205
+ return override
206
+ # Defaults
207
+ return 0.90
208
+
209
+
210
+ def _resolve_max_model_len(size: str, default: int) -> int:
211
+ size_key = size.upper()
212
+ override = _parse_env_int(f"VLLM_MAX_MODEL_LEN_{size_key}")
213
+ if override is not None:
214
+ return override
215
+ override = _parse_env_int("VLLM_MAX_MODEL_LEN")
216
+ if override is not None:
217
+ return override
218
+ return default
loader/perplexity.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import os
3
+ import logging
4
+ from typing import Callable, List, Dict, Any, Optional
5
+
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn.functional as F
9
+
10
+ from .models import BaseModel
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Signature: (question, context, answer_tag, reference_text) -> full_prompt_str
15
+ PromptFormatter = Callable[[str, str, str, str], str]
16
+
17
+
18
+ def default_prompt_formatter(
19
+ question: str,
20
+ context: str,
21
+ answer_tag: str,
22
+ reference_text: str,
23
+ ) -> str:
24
+ """
25
+ Basic prompt layout used by default.
26
+
27
+ You can override this via the `prompt_formatter` argument if a
28
+ specific model needs a different template (e.g., chat template).
29
+ """
30
+ return f"{question}\nContext: {context}\n{answer_tag} {reference_text}"
31
+
32
+
33
+ def _join_prefix_continuation(prefix: str, continuation: str) -> str:
34
+ """Join prefix and continuation with a single space when needed."""
35
+ if not prefix:
36
+ return continuation
37
+ if not continuation:
38
+ return prefix
39
+ if prefix[-1].isspace() or continuation[0].isspace():
40
+ return prefix + continuation
41
+ return prefix + " " + continuation
42
+
43
+
44
+ def _continuation_prompt_formatter(
45
+ _question: str,
46
+ context: str,
47
+ _answer_tag: str,
48
+ reference_text: str,
49
+ ) -> str:
50
+ """Prompt formatter that concatenates context and continuation text."""
51
+ return _join_prefix_continuation(context, reference_text)
52
+
53
+
54
+ def score_continuation(
55
+ model: BaseModel,
56
+ prefix: str,
57
+ continuation: str,
58
+ *,
59
+ max_new_tokens: Optional[int] = None,
60
+ ) -> Dict[str, Any]:
61
+ """
62
+ Compute teacher-forced logprob and perplexity of `continuation` given `prefix`.
63
+ Uses vLLM prompt logprobs when available.
64
+ """
65
+ if not continuation:
66
+ return {
67
+ "avg_logprob": float("-inf"),
68
+ "perplexity": float("inf"),
69
+ "per_token": [],
70
+ "target_len": 0,
71
+ "sequence_logprobs": [],
72
+ }
73
+
74
+ if hasattr(model, "llm"):
75
+ result = score_reference_autoregressive_vllm(
76
+ model,
77
+ question="",
78
+ masked_inputs=[prefix],
79
+ reference_text=continuation,
80
+ answer_tag="",
81
+ prompt_formatter=_continuation_prompt_formatter,
82
+ max_new_tokens=max_new_tokens,
83
+ )
84
+ token_logprobs = result.get("token_logprobs", [])
85
+ per_token = token_logprobs[0] if token_logprobs else []
86
+ target_len = int(result.get("target_len", 0) or 0)
87
+ seq_logprobs = result.get("sequence_logprobs", [])
88
+ if seq_logprobs:
89
+ total_logprob = float(seq_logprobs[0])
90
+ else:
91
+ avg_lp = float(result.get("avg_logprob", float("-inf")))
92
+ total_logprob = avg_lp * target_len if target_len else float("-inf")
93
+ if target_len > 0 and math.isfinite(total_logprob):
94
+ avg_nll = -total_logprob / target_len
95
+ ppl = math.exp(avg_nll)
96
+ else:
97
+ avg_nll = float("inf")
98
+ ppl = float("inf")
99
+ return {
100
+ "avg_logprob": result.get("avg_logprob", float("-inf")),
101
+ "perplexity": result.get("perplexity", float("inf")),
102
+ "total_logprob": total_logprob,
103
+ "avg_nll": avg_nll,
104
+ "per_token": per_token,
105
+ "target_len": target_len,
106
+ "sequence_logprobs": seq_logprobs,
107
+ }
108
+
109
+ raise RuntimeError(
110
+ "score_continuation requires a VLLMModel with .llm "
111
+ "(use loader.get_model_vllm inside the vLLM container)."
112
+ )
113
+
114
+ # --- Real (teacher-forced) scorer using Hugging Face generate() ---
115
+ def score_reference_autoregressive_hf(
116
+ model, # HF AutoModelForCausalLM (on CUDA or CPU)
117
+ tokenizer, # matching HF tokenizer
118
+ question: str, # e.g., "Count the number of r's in strawberry."
119
+ masked_inputs: List[str], # batch of masked contexts (strings)
120
+ reference_token_ids: List[int], # tokenized reference answer ids
121
+ *,
122
+ answer_tag: str = "Answer:",
123
+ prompt_formatter: PromptFormatter = default_prompt_formatter,
124
+ ) -> Dict[str, Any]:
125
+ """
126
+ Teacher-forced next-token log-probs of the reference answer across a batch of masked contexts.
127
+ Returns:
128
+ {
129
+ "avg_logprob": float, # mean over tokens, then mean over batch
130
+ "perplexity": float, # exp(-avg_logprob)
131
+ "target_len": int, # number of next-token steps (len(ref_ids)-1)
132
+ "sequence_logprobs": List[float], # total logprob per masked input (len= batch)
133
+ "token_logprobs": List[List[float]] # per-token logprobs per masked input
134
+ }
135
+ """
136
+ # --- ensure tokenizer/model have a pad token ---
137
+ if tokenizer.pad_token is None:
138
+ if tokenizer.eos_token is not None:
139
+ tokenizer.pad_token = tokenizer.eos_token
140
+ else:
141
+ tokenizer.add_special_tokens({"pad_token": "[PAD]"})
142
+ # only call if model is an HF CausalLM with embeddings
143
+ try:
144
+ model.resize_token_embeddings(len(tokenizer))
145
+ except Exception:
146
+ pass
147
+ tokenizer.padding_side = "left"
148
+ try:
149
+ model.config.pad_token_id = tokenizer.pad_token_id
150
+ except Exception:
151
+ pass
152
+ B = len(masked_inputs)
153
+ # We will accumulate total log-prob of the reference per masked input
154
+ batch_seq_logprobs = np.zeros(B, dtype=np.float64)
155
+
156
+ T = max(0, len(reference_token_ids) - 1) # number of next-token predictions
157
+ if T == 0:
158
+ return {
159
+ "avg_logprob": float("-inf"),
160
+ "perplexity": float("inf"),
161
+ "target_len": 0,
162
+ "sequence_logprobs": batch_seq_logprobs.tolist(),
163
+ }
164
+
165
+ for j in range(T):
166
+ # Reference prefix up to and including token j
167
+ prefix_ids = reference_token_ids[: j + 1]
168
+ # Decode the entire prefix to keep spacing/special-tokens consistent
169
+ prefix_str = tokenizer.decode(prefix_ids, skip_special_tokens=False)
170
+
171
+ # Build prompts for this step
172
+ prompts = [
173
+ prompt_formatter(question, ctx, answer_tag, prefix_str)
174
+ for ctx in masked_inputs
175
+ ]
176
+
177
+ inputs = tokenizer(
178
+ prompts, return_tensors="pt", padding=True, truncation=True
179
+ ).to(model.device)
180
+
181
+ with torch.inference_mode():
182
+ out = model.generate(
183
+ **inputs,
184
+ max_new_tokens=1, # predict exactly one next token
185
+ output_scores=True,
186
+ return_dict_in_generate=True,
187
+ pad_token_id=tokenizer.eos_token_id,
188
+ )
189
+
190
+ # out["scores"] is a list (len = #new tokens = 1) of logits [batch, vocab]
191
+ logits = torch.stack(out["scores"]).swapaxes(0, 1)[:, 0, :] # (B, V)
192
+ logprobs = F.log_softmax(logits, dim=-1)
193
+
194
+ target_next_id = reference_token_ids[j + 1]
195
+ step_lp = logprobs[:, target_next_id].detach().cpu().numpy() # (B,)
196
+
197
+ batch_seq_logprobs += step_lp
198
+
199
+ # clean up to keep memory low
200
+ del inputs, out, logits, logprobs
201
+
202
+ # Average over tokens, then average across the batch
203
+ avg_lp = float((batch_seq_logprobs / T).mean())
204
+ ppl = math.exp(-avg_lp)
205
+
206
+ return {
207
+ "avg_logprob": avg_lp,
208
+ "perplexity": ppl,
209
+ "target_len": T,
210
+ "sequence_logprobs": batch_seq_logprobs.tolist(),
211
+ }
212
+
213
+ def score_reference_autoregressive_vllm(
214
+ vllm_model, # your loader.VLLMModel instance
215
+ question: str,
216
+ masked_inputs: List[str],
217
+ reference_text: str, # e.g., " The answer is 3."
218
+ *,
219
+ answer_tag: str = "Answer:",
220
+ prompt_formatter: PromptFormatter = default_prompt_formatter,
221
+ top_k: int = 5,
222
+ debug: bool = False,
223
+ debug_index: int = 0,
224
+ max_new_tokens: Optional[int] = None,
225
+ ) -> Dict[str, Any]:
226
+ """
227
+ vLLM-native teacher-forced perplexity over a *given* reference answer.
228
+
229
+ Batching behavior:
230
+ - Build one full prompt per masked context.
231
+ - Send ALL prompts to vLLM in a single `llm.generate` call.
232
+ - vLLM returns prompt-level logprobs for each token position.
233
+ - For each prompt, slice out the part corresponding to the answer
234
+ and sum the logprobs of those answer tokens.
235
+
236
+ If debug=True, we print detailed info for the example at `debug_index`:
237
+ - Full prompt string
238
+ - Base vs full token lengths
239
+ - Each answer token + its logprob and running perplexity.
240
+ """
241
+ # 1) Get underlying vLLM engine & tokenizer
242
+ assert hasattr(vllm_model, "llm"), f"Expected VLLMModel wrapper, got {type(vllm_model)}"
243
+
244
+ llm = vllm_model.llm
245
+ try:
246
+ tokenizer = llm.get_tokenizer()
247
+ except Exception as e:
248
+ raise RuntimeError("Could not access vLLM tokenizer; check vLLM version / API") from e
249
+
250
+ B = len(masked_inputs)
251
+ if B == 0:
252
+ return {
253
+ "avg_logprob": float("-inf"),
254
+ "perplexity": float("inf"),
255
+ "target_len": 0,
256
+ "sequence_logprobs": [],
257
+ "token_logprobs": [],
258
+ }
259
+
260
+ # 2) Build all prompts and tokenizations in a batch
261
+ full_prompts: List[str] = []
262
+ base_ids_list: List[List[int]] = []
263
+ full_ids_list: List[List[int]] = []
264
+ ref_ids_list: List[List[int]] = []
265
+
266
+ ref_token_count: Optional[int] = None
267
+
268
+ for ctx in masked_inputs:
269
+ # base prompt: no answer text
270
+ base_prompt = prompt_formatter(question, ctx, answer_tag, "")
271
+ # full prompt: includes gold answer text
272
+ full_prompt = prompt_formatter(question, ctx, answer_tag, reference_text)
273
+
274
+ base_ids = tokenizer(base_prompt, add_special_tokens=False)["input_ids"]
275
+ full_ids = tokenizer(full_prompt, add_special_tokens=False)["input_ids"]
276
+
277
+ # Safety check
278
+ if not full_ids or len(full_ids) <= len(base_ids):
279
+ base_ids_list.append(base_ids)
280
+ full_ids_list.append(full_ids)
281
+ ref_ids_list.append([])
282
+ full_prompts.append(full_prompt)
283
+ continue
284
+
285
+ ref_ids = full_ids[len(base_ids):] # tokens of the answer region
286
+
287
+ if ref_token_count is None:
288
+ ref_token_count = len(ref_ids)
289
+ else:
290
+ # make all answers share a common length (minimum across examples)
291
+ ref_token_count = min(ref_token_count, len(ref_ids))
292
+
293
+ base_ids_list.append(base_ids)
294
+ full_ids_list.append(full_ids)
295
+ ref_ids_list.append(ref_ids)
296
+ full_prompts.append(full_prompt)
297
+
298
+ # If no valid ref tokens at all, bail out
299
+ if ref_token_count is None or ref_token_count == 0:
300
+ return {
301
+ "avg_logprob": float("-inf"),
302
+ "perplexity": float("inf"),
303
+ "target_len": 0,
304
+ "sequence_logprobs": [float("-inf")] * B,
305
+ "token_logprobs": [[] for _ in range(B)],
306
+ }
307
+
308
+ # Truncate all ref_ids to the common length
309
+ ref_len = ref_token_count
310
+ ref_ids_list = [ref_ids[:ref_len] for ref_ids in ref_ids_list]
311
+
312
+ # 3) Call vLLM *once* on all full prompts (batched)
313
+ from vllm import SamplingParams
314
+
315
+ sp = SamplingParams(
316
+ max_tokens=max_new_tokens or 1, # keep tiny generation; default minimal
317
+ temperature=0.0,
318
+ top_p=1.0,
319
+ logprobs=top_k, # store top-k logprobs per position
320
+ prompt_logprobs=1, # request logprobs for prompt tokens
321
+ )
322
+
323
+ results = llm.generate(full_prompts, sp)
324
+
325
+ # 4) For each prompt, sum the logprobs over the answer segment
326
+ per_seq_logprobs: List[float] = []
327
+ per_seq_token_logprobs: List[List[float]] = []
328
+
329
+ def to_float(v):
330
+ # v might be a Logprob object or a raw float
331
+ return float(getattr(v, "logprob", v))
332
+
333
+ for idx_ex, (base_ids, full_ids, ref_ids) in enumerate(
334
+ zip(base_ids_list, full_ids_list, ref_ids_list)
335
+ ):
336
+ if not ref_ids or not full_ids:
337
+ per_seq_logprobs.append(float("-inf"))
338
+ per_seq_token_logprobs.append([])
339
+ continue
340
+
341
+ req_out = results[idx_ex]
342
+ prompt_logprobs = getattr(req_out, "prompt_logprobs", None)
343
+ if prompt_logprobs is None:
344
+ raise RuntimeError(
345
+ "req_out.prompt_logprobs is None. Adjust to match your vLLM version."
346
+ )
347
+
348
+ # Align lengths: vLLM may add BOS token
349
+ if len(prompt_logprobs) == len(full_ids) + 1:
350
+ prompt_logprobs = prompt_logprobs[1:]
351
+ elif len(prompt_logprobs) != len(full_ids):
352
+ L = min(len(prompt_logprobs), len(full_ids))
353
+ prompt_logprobs = prompt_logprobs[:L]
354
+ full_ids = full_ids[:L]
355
+
356
+ seq_lp = 0.0
357
+ token_logprobs: List[float] = []
358
+
359
+ # Optional debug header
360
+ if debug and idx_ex == debug_index:
361
+ print("\n=== DEBUG vLLM PPL example", idx_ex, "===")
362
+ print("Full prompt:\n", full_prompts[idx_ex])
363
+ print("\nBase token length:", len(base_ids))
364
+ print("Full token length:", len(full_ids))
365
+ print("Answer token length (ref_len):", ref_len)
366
+ print("\nAnswer tokens and logprobs:")
367
+
368
+ for offset, token_id in enumerate(ref_ids[:ref_len]):
369
+ pos = len(base_ids) + offset # index in full sequence
370
+
371
+ if pos >= len(prompt_logprobs):
372
+ token_lp = -20.0
373
+ seq_lp += token_lp
374
+ token_logprobs.append(token_lp)
375
+ if debug and idx_ex == debug_index:
376
+ tok_str = tokenizer.convert_ids_to_tokens([token_id])[0]
377
+ print(f" pos={pos:<3} token={tok_str!r:>10} logprob={token_lp: .4f} (OUT OF RANGE)")
378
+ continue
379
+
380
+ cand_dict = prompt_logprobs[pos] or {}
381
+
382
+ if token_id in cand_dict:
383
+ token_lp = to_float(cand_dict[token_id])
384
+ else:
385
+ if cand_dict:
386
+ floor = min(to_float(v) for v in cand_dict.values())
387
+ token_lp = floor - 5.0
388
+ else:
389
+ token_lp = -20.0
390
+
391
+ seq_lp += token_lp
392
+ token_logprobs.append(token_lp)
393
+
394
+ if debug and idx_ex == debug_index:
395
+ tok_str = tokenizer.convert_ids_to_tokens([token_id])[0]
396
+ avg_lp_so_far = seq_lp / (offset + 1)
397
+ ppl_so_far = math.exp(-avg_lp_so_far)
398
+ print(
399
+ f" pos={pos:<3} token={tok_str!r:>10} "
400
+ f"logprob={token_lp: .4f} "
401
+ f"cum_avg_lp={avg_lp_so_far: .4f} "
402
+ f"cum_ppl={ppl_so_far: .4f}"
403
+ )
404
+
405
+ per_seq_logprobs.append(seq_lp)
406
+ per_seq_token_logprobs.append(token_logprobs)
407
+
408
+ # 5) Aggregate across examples
409
+ arr = np.array(per_seq_logprobs, dtype=np.float64)
410
+ T = int(ref_len)
411
+
412
+ if T == 0:
413
+ return {
414
+ "avg_logprob": float("-inf"),
415
+ "perplexity": float("inf"),
416
+ "target_len": 0,
417
+ "sequence_logprobs": per_seq_logprobs,
418
+ "token_logprobs": per_seq_token_logprobs,
419
+ }
420
+
421
+ avg_lp = float((arr / T).mean())
422
+ ppl = math.exp(-avg_lp)
423
+
424
+ return {
425
+ "avg_logprob": avg_lp,
426
+ "perplexity": ppl,
427
+ "target_len": T,
428
+ "sequence_logprobs": per_seq_logprobs,
429
+ "token_logprobs": per_seq_token_logprobs,
430
+ }
431
+
432
+
433
+ def score_continuation_batch(
434
+ model: BaseModel,
435
+ prefixes: List[str],
436
+ continuation: str,
437
+ *,
438
+ max_new_tokens: Optional[int] = None,
439
+ batch_size: Optional[int] = None,
440
+ ) -> List[Dict[str, Any]]:
441
+ """
442
+ Batched variant of score_continuation. Returns list aligned to prefixes.
443
+ """
444
+ if not prefixes:
445
+ return []
446
+
447
+ B = batch_size or int(os.getenv("VLLM_SCORE_BATCH_SIZE",
448
+ os.getenv("ATTRLLM_VLLM_BATCH_SIZE", "128")))
449
+ B = max(1, int(B))
450
+ outputs: List[Optional[Dict[str, Any]]] = [None] * len(prefixes)
451
+
452
+ def _failed_output() -> Dict[str, Any]:
453
+ return {
454
+ "avg_logprob": float("-inf"),
455
+ "perplexity": float("inf"),
456
+ "total_logprob": float("-inf"),
457
+ "avg_nll": float("inf"),
458
+ "per_token": [],
459
+ "target_len": 0,
460
+ "sequence_logprobs": [float("-inf")],
461
+ }
462
+
463
+ def _score_chunk(chunk_prefixes: List[str], offset: int, chunk_batch_size: int) -> None:
464
+ if not chunk_prefixes:
465
+ return
466
+ try:
467
+ res = score_reference_autoregressive_vllm(
468
+ model,
469
+ question="",
470
+ masked_inputs=chunk_prefixes,
471
+ reference_text=continuation,
472
+ answer_tag="",
473
+ prompt_formatter=_continuation_prompt_formatter,
474
+ max_new_tokens=max_new_tokens,
475
+ )
476
+ seq_lp = res.get("sequence_logprobs", [])
477
+ per_token = res.get("token_logprobs", [])
478
+ target_len = int(res.get("target_len", 0) or 0)
479
+
480
+ if len(seq_lp) != len(chunk_prefixes):
481
+ raise RuntimeError(
482
+ "vLLM returned mismatched sequence_logprobs length: "
483
+ f"expected={len(chunk_prefixes)} got={len(seq_lp)}"
484
+ )
485
+
486
+ for i, lp in enumerate(seq_lp):
487
+ idx = offset + i
488
+ total_logprob = float(lp)
489
+ if target_len > 0 and math.isfinite(total_logprob):
490
+ avg_nll = -total_logprob / target_len
491
+ ppl = math.exp(avg_nll)
492
+ else:
493
+ avg_nll = float("inf")
494
+ ppl = float("inf")
495
+ outputs[idx] = {
496
+ "avg_logprob": res.get("avg_logprob", float("-inf")),
497
+ "perplexity": res.get("perplexity", ppl),
498
+ "total_logprob": total_logprob,
499
+ "avg_nll": avg_nll,
500
+ "per_token": per_token[i] if i < len(per_token) else [],
501
+ "target_len": target_len,
502
+ "sequence_logprobs": [total_logprob],
503
+ }
504
+ except Exception as exc:
505
+ # vLLM occasionally asserts in large-batch prompt_logprob scoring.
506
+ # Back off to smaller chunks instead of failing the whole request.
507
+ n = len(chunk_prefixes)
508
+ if n == 1:
509
+ logger.warning(
510
+ "score_continuation_batch failed for single prefix; returning -inf fallback. error=%s",
511
+ exc,
512
+ )
513
+ outputs[offset] = _failed_output()
514
+ return
515
+
516
+ next_batch_size = max(1, min(chunk_batch_size // 2, n // 2))
517
+ logger.warning(
518
+ "[BACKOFF_ACTIVE] score_continuation_batch chunk failed; retrying smaller chunks. "
519
+ "chunk_size=%d batch_size=%d next_batch_size=%d error=%s",
520
+ n,
521
+ chunk_batch_size,
522
+ next_batch_size,
523
+ exc,
524
+ )
525
+ for sub_start in range(0, n, next_batch_size):
526
+ sub_chunk = chunk_prefixes[sub_start : sub_start + next_batch_size]
527
+ _score_chunk(sub_chunk, offset + sub_start, next_batch_size)
528
+
529
+ for start in range(0, len(prefixes), B):
530
+ chunk = prefixes[start : start + B]
531
+ _score_chunk(chunk, start, B)
532
+
533
+ # Guarantee alignment even in worst-case failures.
534
+ for i, item in enumerate(outputs):
535
+ if item is None:
536
+ outputs[i] = _failed_output()
537
+
538
+ return outputs # type: ignore[return-value]
loader/results.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, Tuple, Optional
2
+ from pathlib import Path
3
+ import copy
4
+ import json
5
+ import os
6
+ from itertools import combinations
7
+ try:
8
+ from attribution.utils import (
9
+ influence_interactions,
10
+ mobius_to_influence,
11
+ mobius_to_shapley,
12
+ mobius_to_banzhaf,
13
+ shapley_interactions,
14
+ banzhaf_interactions,
15
+ )
16
+ except Exception: # pragma: no cover - keep loader resilient if attribution stack is unavailable
17
+ influence_interactions = None
18
+ mobius_to_influence = None
19
+ mobius_to_shapley = None
20
+ mobius_to_banzhaf = None
21
+ shapley_interactions = None
22
+ banzhaf_interactions = None
23
+
24
+ try:
25
+ from huggingface_hub import hf_hub_download
26
+ except Exception: # pragma: no cover
27
+ hf_hub_download = None
28
+
29
+
30
+ def _resolve_results_dir() -> Path:
31
+ """Resolve results directory: env, repo root, or on HF Space fallback to cwd/results."""
32
+ env_dir = os.getenv("ATTRLLM_RESULTS_DIR")
33
+ if env_dir:
34
+ return Path(env_dir).resolve()
35
+ repo_root = Path(__file__).resolve().parent.parent
36
+ default = (repo_root / "results").resolve()
37
+ if default.exists():
38
+ return default
39
+ # Hugging Face Space: repo may be at /app but cwd can be workspace root with results/
40
+ if os.getenv("SPACE_ID") or os.getenv("HF_SPACE"):
41
+ cwd_results = (Path.cwd() / "results").resolve()
42
+ if cwd_results.exists():
43
+ return cwd_results
44
+ return default
45
+
46
+
47
+ _RESULTS_DIR = _resolve_results_dir()
48
+ _CACHE: Dict[Path, Dict[str, Any]] = {}
49
+
50
+
51
+ def _maybe_download_from_space(path: Path, force_download: bool = False) -> Optional[Path]:
52
+ """Best-effort download from Hugging Face Space repo if running in Spaces.
53
+ When force_download=True, re-fetches from Hub (can resolve LFS/Xet pointers in container)."""
54
+ if hf_hub_download is None:
55
+ return None
56
+ repo_id = os.getenv("SPACE_ID")
57
+ if not repo_id:
58
+ return None
59
+ rel = None
60
+ try:
61
+ rel = path.relative_to(Path.cwd())
62
+ except Exception:
63
+ try:
64
+ repo_root = Path(__file__).resolve().parent.parent
65
+ rel = path.relative_to(repo_root)
66
+ except Exception:
67
+ rel = None
68
+ if rel is None:
69
+ return None
70
+ try:
71
+ kwargs = {
72
+ "repo_id": repo_id,
73
+ "filename": str(rel),
74
+ "repo_type": "space",
75
+ "local_dir": str(Path.cwd()),
76
+ "local_dir_use_symlinks": False,
77
+ }
78
+ if force_download:
79
+ kwargs["force_download"] = True
80
+ local_path = hf_hub_download(**kwargs)
81
+ return Path(local_path)
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def _dataset_results_path(model_size: str, dataset: str) -> Path:
87
+ # New layout:
88
+ # results/reference_answer/{model_size}/{dataset}/{example_id}/{scalarizer}/{feature_level}.json
89
+ return _RESULTS_DIR / "reference_answer" / model_size / dataset
90
+
91
+
92
+ def _public_results_path(dataset: str) -> Path:
93
+ # Legacy/public layout:
94
+ # results/public/{dataset}/{example_id}/{scalarizer}/{feature_level}/{method}.json
95
+ return _RESULTS_DIR / "public" / dataset
96
+
97
+
98
+ def _find_reference_example_dir(
99
+ dataset: str,
100
+ ex_id: str,
101
+ preferred_size: Optional[str] = None,
102
+ ) -> Tuple[Optional[str], Optional[Path]]:
103
+ """Locate an existing reference_answer example dir across model sizes."""
104
+ sizes = [s for s in (preferred_size, "large", "medium", "small") if s]
105
+ seen = set()
106
+ for size in sizes:
107
+ if size in seen:
108
+ continue
109
+ seen.add(size)
110
+ candidate = _dataset_results_path(size, dataset) / ex_id
111
+ if candidate.exists():
112
+ return size, candidate
113
+ return None, None
114
+
115
+
116
+ def _load_dataset_results(path: Path) -> Dict[str, Any]:
117
+ if path in _CACHE:
118
+ return _CACHE[path]
119
+ # On HF Space, fetch from Hub first so we get resolved content (not LFS/Xet pointer in container).
120
+ if os.getenv("SPACE_ID"):
121
+ fetched = _maybe_download_from_space(path, force_download=True)
122
+ if fetched and fetched.exists():
123
+ path = fetched
124
+ if not path.exists():
125
+ fetched = _maybe_download_from_space(path)
126
+ if fetched and fetched.exists():
127
+ path = fetched
128
+ else:
129
+ _CACHE[path] = {}
130
+ return _CACHE[path]
131
+ try:
132
+ with path.open("r", encoding="utf-8") as f:
133
+ data = json.load(f)
134
+ except Exception:
135
+ # If this is a Git LFS/Xet pointer or otherwise unreadable JSON,
136
+ # try to fetch the real file from the Space repo.
137
+ fetched = _maybe_download_from_space(path)
138
+ if fetched and fetched.exists():
139
+ try:
140
+ with fetched.open("r", encoding="utf-8") as f:
141
+ data = json.load(f)
142
+ except Exception:
143
+ _CACHE[path] = {}
144
+ return _CACHE[path]
145
+ else:
146
+ _CACHE[path] = {}
147
+ return _CACHE[path]
148
+ _CACHE[path] = data if isinstance(data, dict) else {}
149
+ return _CACHE[path]
150
+
151
+
152
+ def _load_method_payload(
153
+ example_dir: Path,
154
+ method: str,
155
+ scalarizer: Optional[str] = None,
156
+ feature_level: Optional[str] = None,
157
+ ) -> Dict[str, Any]:
158
+ """Load the Möbius-dict payload for the given example.
159
+
160
+ New layout (method-agnostic files):
161
+ {example_dir}/{scalarizer}/{feature_level}.json
162
+
163
+ The ``method`` parameter is not used for file lookup (the Möbius dict
164
+ is identical for Shapley and Banzhaf); it is only passed through so
165
+ the caller can apply the correct aggregation afterwards.
166
+ """
167
+ scalarizer = (scalarizer or "").strip()
168
+ feature_level = (feature_level or "").strip()
169
+
170
+ candidates = []
171
+ if scalarizer and feature_level:
172
+ candidates.append(example_dir / scalarizer / f"{feature_level}.json")
173
+ # If the requested level is missing, fall back to other levels.
174
+ for lvl in ("word", "sentence", "paragraph"):
175
+ if lvl != feature_level:
176
+ candidates.append(example_dir / scalarizer / f"{lvl}.json")
177
+ elif scalarizer:
178
+ for lvl in ("word", "sentence", "paragraph"):
179
+ candidates.append(example_dir / scalarizer / f"{lvl}.json")
180
+ elif feature_level:
181
+ for sc in ("semantic_similarity", "geomean_jointprob", "logprob", "jointprob", "half_simlog"):
182
+ candidates.append(example_dir / sc / f"{feature_level}.json")
183
+ else:
184
+ # Default preference order
185
+ candidates.extend(
186
+ [
187
+ example_dir / "semantic_similarity" / "word.json",
188
+ example_dir / "semantic_similarity" / "sentence.json",
189
+ example_dir / "semantic_similarity" / "paragraph.json",
190
+ ]
191
+ )
192
+
193
+ # Legacy fallback: old layout with method in filename
194
+ # (e.g. {scalarizer}/{feature_level}/shapley.json)
195
+ method_lower = str(method or "shapley").lower()
196
+ if scalarizer and feature_level:
197
+ candidates.append(example_dir / scalarizer / feature_level / f"{method_lower}.json")
198
+ elif scalarizer:
199
+ for lvl in ("word", "sentence", "paragraph"):
200
+ candidates.append(example_dir / scalarizer / lvl / f"{method_lower}.json")
201
+
202
+ for candidate in candidates:
203
+ payload = _load_dataset_results(candidate)
204
+ if payload:
205
+ return payload
206
+
207
+ return {}
208
+
209
+
210
+ def _parse_sparse_key(raw_key: str) -> Tuple[int, ...]:
211
+ key = str(raw_key).strip()
212
+ if not key:
213
+ return ()
214
+ return tuple(int(part) for part in key.split(",") if part != "")
215
+
216
+
217
+ def _assign_unique_labels(chunks):
218
+ counts: Dict[str, int] = {}
219
+ labels = []
220
+ for idx, chunk in enumerate(chunks):
221
+ normalized = " ".join((str(chunk) if chunk is not None else "").split())
222
+ if not normalized:
223
+ normalized = "<chunk %d>" % (idx + 1)
224
+ counts[normalized] = counts.get(normalized, 0) + 1
225
+ suffix = " (%d)" % counts[normalized] if counts[normalized] > 1 else ""
226
+ labels.append("%s%s" % (normalized, suffix))
227
+ return labels
228
+
229
+
230
+ def _normalize_public_payload(payload: Dict[str, Any], method: str, top_k: int = 10) -> Dict[str, Any]:
231
+ if not isinstance(payload, dict):
232
+ return {}
233
+
234
+ normalized = dict(payload)
235
+ features = normalized.get("features")
236
+ mobius_raw = normalized.get("mobius_dict")
237
+ if not isinstance(features, list) or not isinstance(mobius_raw, dict):
238
+ return normalized
239
+
240
+ # Keep existing explicit fields if they are already present.
241
+ if isinstance(normalized.get("token_scores"), dict) and (
242
+ isinstance(normalized.get("pairwise"), dict) or isinstance(normalized.get("interactions_2"), dict)
243
+ ):
244
+ return normalized
245
+
246
+ method = (method or "shapley").lower()
247
+ if method not in {"shapley", "banzhaf", "influence"}:
248
+ method = "shapley"
249
+
250
+ mobius_sparse: Dict[Tuple[int, ...], float] = {}
251
+ for key, raw_val in mobius_raw.items():
252
+ try:
253
+ val = float(raw_val)
254
+ except Exception:
255
+ continue
256
+ try:
257
+ loc = _parse_sparse_key(str(key))
258
+ except Exception:
259
+ continue
260
+ mobius_sparse[tuple(sorted(loc))] = val
261
+
262
+ token_scores: Dict[str, float] = {}
263
+ index_scores: Dict[int, float] = {}
264
+ pairwise_acc: Dict[Tuple[int, int], float] = {}
265
+
266
+ if mobius_sparse and mobius_to_shapley is not None:
267
+ if method == "shapley":
268
+ singleton_dict = mobius_to_shapley(mobius_sparse)
269
+ pair_list = shapley_interactions(mobius_sparse, order=2, top_k=top_k) or []
270
+ elif method == "banzhaf":
271
+ singleton_dict = mobius_to_banzhaf(mobius_sparse)
272
+ pair_list = banzhaf_interactions(mobius_sparse, order=2, top_k=top_k) or []
273
+ elif mobius_to_influence is not None and influence_interactions is not None:
274
+ singleton_dict = mobius_to_influence(mobius_sparse)
275
+ pair_list = influence_interactions(mobius_sparse, order=2, top_k=top_k) or []
276
+ else:
277
+ singleton_dict = {}
278
+ pair_list = []
279
+
280
+ for loc, val in singleton_dict.items():
281
+ if len(loc) != 1:
282
+ continue
283
+ idx = int(loc[0])
284
+ if 0 <= idx < len(features):
285
+ feat_name = str(features[idx])
286
+ val_f = float(val)
287
+ token_scores[feat_name] = token_scores.get(feat_name, 0.0) + val_f
288
+ index_scores[idx] = index_scores.get(idx, 0.0) + val_f
289
+
290
+ for loc, val in pair_list:
291
+ if len(loc) != 2:
292
+ continue
293
+ i, j = int(loc[0]), int(loc[1])
294
+ if 0 <= i < len(features) and 0 <= j < len(features):
295
+ key = (i, j) if i <= j else (j, i)
296
+ pairwise_acc[key] = float(val)
297
+ else:
298
+ # Fallback path: local conversion if attribution utils are unavailable.
299
+ for loc, val in mobius_sparse.items():
300
+ k = len(loc)
301
+ if k == 0:
302
+ continue
303
+ if method == "shapley":
304
+ singleton_weight = 1.0 / float(k)
305
+ elif method == "banzhaf":
306
+ singleton_weight = 1.0 / float(2 ** (k - 1))
307
+ else:
308
+ singleton_weight = 1.0 / float(k)
309
+ for idx in loc:
310
+ if 0 <= idx < len(features):
311
+ feat_name = str(features[idx])
312
+ token_scores[feat_name] = token_scores.get(feat_name, 0.0) + singleton_weight * val
313
+ index_scores[idx] = index_scores.get(idx, 0.0) + singleton_weight * val
314
+ if k >= 2:
315
+ if method == "shapley":
316
+ pairwise_weight = 1.0 / float(k - 1)
317
+ elif method == "banzhaf":
318
+ pairwise_weight = 1.0 / float(2 ** (k - 2))
319
+ else:
320
+ pairwise_weight = 1.0 / float(k - 1)
321
+ for i, j in combinations(sorted(loc), 2):
322
+ pairwise_acc[(i, j)] = pairwise_acc.get((i, j), 0.0) + pairwise_weight * val
323
+
324
+ if token_scores and not isinstance(normalized.get("token_scores"), dict):
325
+ normalized["token_scores"] = token_scores
326
+
327
+ unique_feature_labels = [str(x) for x in features]
328
+ if index_scores and features and not (
329
+ isinstance(normalized.get("features"), list)
330
+ and normalized["features"]
331
+ and isinstance(normalized["features"][0], dict)
332
+ ):
333
+ original_feature_texts = [str(x) for x in features]
334
+ unique_feature_labels = _assign_unique_labels(original_feature_texts)
335
+ normalized["feature_texts"] = original_feature_texts
336
+ normalized["features"] = [
337
+ {
338
+ "feature": original_feature_texts[i],
339
+ "value": float(index_scores.get(i, 0.0)),
340
+ "index": i,
341
+ }
342
+ for i in range(len(original_feature_texts))
343
+ ]
344
+
345
+ if pairwise_acc and not isinstance(normalized.get("pairwise"), dict):
346
+ sorted_pairs = sorted(pairwise_acc.items(), key=lambda kv: abs(kv[1]), reverse=True)
347
+ if top_k and top_k > 0:
348
+ sorted_pairs = sorted_pairs[:top_k]
349
+ # Use feature labels (not raw indices) so chips/text views can map edges directly.
350
+ normalized["pairwise"] = {
351
+ "%s|%s" % (unique_feature_labels[i], unique_feature_labels[j]): float(v)
352
+ for (i, j), v in sorted_pairs
353
+ if 0 <= i < len(unique_feature_labels) and 0 <= j < len(unique_feature_labels)
354
+ }
355
+ normalized["pairwise_interactions"] = [
356
+ {"features": [unique_feature_labels[i], unique_feature_labels[j]], "value": float(v)}
357
+ for (i, j), v in sorted_pairs
358
+ if 0 <= i < len(unique_feature_labels) and 0 <= j < len(unique_feature_labels)
359
+ ]
360
+
361
+ return normalized
362
+
363
+ def get_result_by_id(
364
+ model_size: str,
365
+ dataset: str,
366
+ ex_id: str,
367
+ scalarizer: Optional[str] = None,
368
+ feature_level: Optional[str] = None,
369
+ ) -> Dict:
370
+ """
371
+ Retrieve saved interaction results (e.g., from Attribution) for a datapoint.
372
+ Expected to return Shapley, Banzhaf, and Influence views
373
+ (and their interactions) as JSON-like dict.
374
+
375
+ Args:
376
+ model_size: "small" | "medium" | "large"
377
+ dataset: dataset name (e.g., "reasoning")
378
+ ex_id: example id (e.g., "ex1")
379
+
380
+ Returns (example schema):
381
+ {
382
+ "meta": {"model_size": "small", "dataset": "reasoning", "ex_id": "ex1"},
383
+ "shapley": { "token_scores": {...}, "pairwise": {...}, "higher_order": {...} },
384
+ "banzhaf": { "token_scores": {...}, "pairwise": {...}, "higher_order": {...} },
385
+ "influence": { "token_scores": {...}, "pairwise": {...}, "higher_order": {...} }
386
+ }
387
+ """
388
+ dataset_root = _dataset_results_path(model_size, dataset)
389
+ example_dir = dataset_root / ex_id
390
+ if not example_dir.exists():
391
+ alt_size, alt_dir = _find_reference_example_dir(dataset, ex_id, model_size)
392
+ if alt_dir is not None:
393
+ model_size = alt_size or model_size
394
+ example_dir = alt_dir
395
+
396
+ # New layout: a single method-agnostic file per (scalarizer, feature_level).
397
+ # Load it once and derive both Shapley and Banzhaf aggregations.
398
+ if example_dir.exists():
399
+ payload = _load_method_payload(
400
+ example_dir,
401
+ "shapley", # method param unused for file lookup in new layout
402
+ scalarizer=scalarizer,
403
+ feature_level=feature_level,
404
+ )
405
+ if payload:
406
+ record: Dict[str, Any] = {}
407
+ record["shapley"] = _normalize_public_payload(copy.deepcopy(payload), "shapley")
408
+ record["banzhaf"] = _normalize_public_payload(copy.deepcopy(payload), "banzhaf")
409
+ record["influence"] = _normalize_public_payload(copy.deepcopy(payload), "influence")
410
+
411
+ source_meta: Dict[str, Any] = {
412
+ "dataset": dataset,
413
+ "example_id": ex_id,
414
+ "model_size": model_size,
415
+ "source_layout": "results/reference_answer/{model_size}/{dataset}/{example_id}/{scalarizer}/{feature_level}.json",
416
+ }
417
+ file_meta = payload.get("meta") if isinstance(payload, dict) else None
418
+ if isinstance(file_meta, dict):
419
+ source_meta.update(file_meta)
420
+ record["meta"] = source_meta
421
+ return record
422
+
423
+ # Fallback: legacy/public layout with method-specific files.
424
+ public_root = _public_results_path(dataset)
425
+ public_example_dir = public_root / ex_id
426
+ if not public_example_dir.exists():
427
+ return {}
428
+
429
+ shapley_payload = _load_method_payload(
430
+ public_example_dir,
431
+ "shapley",
432
+ scalarizer=scalarizer,
433
+ feature_level=feature_level,
434
+ )
435
+ banzhaf_payload = _load_method_payload(
436
+ public_example_dir,
437
+ "banzhaf",
438
+ scalarizer=scalarizer,
439
+ feature_level=feature_level,
440
+ )
441
+ if not shapley_payload and not banzhaf_payload:
442
+ return {}
443
+
444
+ record = {}
445
+ if shapley_payload:
446
+ record["shapley"] = _normalize_public_payload(copy.deepcopy(shapley_payload), "shapley")
447
+ if banzhaf_payload:
448
+ record["banzhaf"] = _normalize_public_payload(copy.deepcopy(banzhaf_payload), "banzhaf")
449
+ if shapley_payload:
450
+ record["influence"] = _normalize_public_payload(copy.deepcopy(shapley_payload), "influence")
451
+ elif banzhaf_payload:
452
+ record["influence"] = _normalize_public_payload(copy.deepcopy(banzhaf_payload), "influence")
453
+
454
+ source_meta = {
455
+ "dataset": dataset,
456
+ "example_id": ex_id,
457
+ "model_size": model_size,
458
+ "source_layout": "results/public/{dataset}/{example_id}/{scalarizer}/{feature_level}/{method}.json",
459
+ }
460
+ # Prefer per-file meta if available.
461
+ for payload in (shapley_payload, banzhaf_payload):
462
+ file_meta = payload.get("meta") if isinstance(payload, dict) else None
463
+ if isinstance(file_meta, dict):
464
+ source_meta.update(file_meta)
465
+ break
466
+ record["meta"] = source_meta
467
+
468
+ return record
loader/utils.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /projects/bfoj/AttrLLM/loader/utils.py
2
+ import os
3
+ import random
4
+ import platform
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Any, Optional
7
+
8
+ try:
9
+ import torch
10
+ except Exception:
11
+ torch = None
12
+
13
+ @dataclass
14
+ class DeviceInfo:
15
+ python_version: str
16
+ platform: str
17
+ cuda_available: bool
18
+ cuda_device: Optional[str]
19
+ torch_version: Optional[str]
20
+
21
+ def device_info() -> Dict[str, Any]:
22
+ """Return a dict with basic runtime & device diagnostics."""
23
+ cuda_avail = bool(torch and torch.cuda.is_available())
24
+ return {
25
+ "python_version": platform.python_version(),
26
+ "platform": platform.platform(),
27
+ "torch_version": getattr(torch, "__version__", None),
28
+ "cuda_available": cuda_avail,
29
+ "cuda_device": (torch.cuda.get_device_name(0) if (cuda_avail and torch) else None),
30
+ }
31
+
32
+ def set_seed(seed: int) -> None:
33
+ """Seed Python & Torch RNGs for reproducibility (best-effort)."""
34
+ random.seed(seed)
35
+ if torch:
36
+ try:
37
+ torch.manual_seed(seed)
38
+ if torch.cuda.is_available():
39
+ torch.cuda.manual_seed_all(seed)
40
+ except Exception:
41
+ pass
42
+ os.environ["PYTHONHASHSEED"] = str(seed)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ requests
2
+ pillow
3
+ plotly
4
+ numpy
5
+ pandas
6
+ matplotlib
7
+ scipy
8
+ huggingface_hub==0.25.2
9
+ lightgbm
10
+ scikit-learn
11
+ torch
results/coco_mm/coco_133233_original.png ADDED

Git LFS Details

  • SHA256: fa4e0e594999234ccb1ee5d23d89067dfacd515a842554239b5263ca50e8e31b
  • Pointer size: 131 Bytes
  • Size of remote file: 439 kB
results/coco_mm/coco_133233_overlay.png ADDED

Git LFS Details

  • SHA256: 4c0f8e3ebc37fdf4a0ec976b3cf05f869d768fbad52663824fa92eafcd4dbc3d
  • Pointer size: 131 Bytes
  • Size of remote file: 462 kB
results/coco_mm/coco_133233_segmap.png ADDED

Git LFS Details

  • SHA256: 862cd4b29eb8446789867237e7edeea89d211511a8d310eba5cd03dd7c3a43aa
  • Pointer size: 131 Bytes
  • Size of remote file: 447 kB
results/coco_mm/coco_133233_summary.txt ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === COCO 133233 – shapley CROSS-MODAL attributions ===
2
+
3
+ Caption: A marina filled with boats floating in crystal blue water.
4
+
5
+ Image region values (7 regions):
6
+ seg_4 -0.810950
7
+ seg_3 +0.781893
8
+ seg_0 +0.452894
9
+ seg_1 +0.282716
10
+ seg_5 +0.277897
11
+ seg_2 -0.238533
12
+ seg_6 +0.131396
13
+
14
+ Caption token values (11 tokens):
15
+ tok:a +2.337490
16
+ tok:marina +1.058479
17
+ tok:floating +0.194257
18
+ tok:. +0.173353
19
+ tok:boats -0.171325
20
+ tok:in -0.132036
21
+ tok:with -0.123359
22
+ tok:water +0.083010
23
+ tok:filled -0.064285
24
+ tok:blue -0.042108
25
+ tok:crystal +0.017018
26
+
27
+ Image×Image interactions (15):
28
+ seg_0 × seg_2 +1.001538
29
+ seg_2 × seg_4 -0.419727
30
+ seg_0 × seg_1 +0.414486
31
+ seg_3 × seg_6 +0.312252
32
+ seg_0 × seg_4 -0.312040
33
+ seg_2 × seg_6 -0.302635
34
+ seg_2 × seg_3 -0.283335
35
+ seg_0 × seg_5 +0.261252
36
+ seg_0 × seg_3 +0.223211
37
+ seg_2 × seg_5 +0.221214
38
+ seg_3 × seg_5 +0.188386
39
+ seg_1 × seg_3 +0.174208
40
+ seg_1 × seg_5 -0.121616
41
+ seg_0 × seg_6 +0.087689
42
+ seg_1 × seg_4 +0.077673
43
+
44
+ Token×Token interactions (15):
45
+ tok:a × tok:marina +2.073310
46
+ tok:a × tok:floating +0.331114
47
+ tok:a × tok:boats -0.304516
48
+ tok:a × tok:. +0.284624
49
+ tok:floating × tok:in -0.161938
50
+ tok:filled × tok:floating +0.157476
51
+ tok:with × tok:. -0.139770
52
+ tok:boats × tok:blue -0.138589
53
+ tok:boats × tok:. -0.138023
54
+ tok:a × tok:in +0.118445
55
+ tok:filled × tok:with -0.105322
56
+ tok:a × tok:blue +0.103642
57
+ tok:filled × tok:in -0.100758
58
+ tok:a × tok:with +0.095216
59
+ tok:filled × tok:blue -0.094530
60
+
61
+ Top-5 Cross-Modal interactions (seg × tok):
62
+ seg_0 × tok:a +1.411575
63
+ seg_3 × tok:a +0.862141
64
+ seg_2 × tok:a +0.710077
65
+ seg_4 × tok:a -0.695077
66
+ seg_5 × tok:a +0.515772
67
+
68
+ ★ Positive token #1: tok:a (+2.337490) × top-3 segments:
69
+ seg_0 × tok:a +1.411575
70
+ seg_3 × tok:a +0.862141
71
+ seg_2 × tok:a +0.710077
72
+
73
+ ★ Positive token #2: tok:marina (+1.058479) × top-3 segments:
74
+ seg_0 × tok:marina -0.148232
75
+ seg_3 × tok:marina -0.129773
76
+ seg_1 × tok:marina +0.069036
77
+
78
+ ★ Negative token #1: tok:boats (-0.171325) × top-3 segments:
79
+ seg_3 × tok:boats +0.214681
80
+ seg_6 × tok:boats +0.152483
81
+ seg_0 × tok:boats -0.133591
82
+
83
+ ★ Negative token #2: tok:in (-0.132036) × top-3 segments:
84
+ seg_3 × tok:in -0.152741
85
+ seg_5 × tok:in -0.145348
86
+ seg_1 × tok:in -0.114416
87
+
88
+ ============================================================
89
+ Per-token cross-modal interactions (top-5 each):
90
+
91
+ tok:a (Shapley=+2.337490):
92
+ seg_0 × tok:a +1.411575
93
+ seg_3 × tok:a +0.862141
94
+ seg_2 × tok:a +0.710077
95
+ seg_4 × tok:a -0.695077
96
+ seg_5 × tok:a +0.515772
97
+
98
+ tok:marina (Shapley=+1.058479):
99
+ seg_0 × tok:marina -0.148232
100
+ seg_3 × tok:marina -0.129773
101
+ seg_1 × tok:marina +0.069036
102
+ seg_4 × tok:marina -0.056511
103
+ seg_6 × tok:marina +0.036980
104
+
105
+ tok:filled (Shapley=-0.064285):
106
+ seg_3 × tok:filled -0.286883
107
+ seg_0 × tok:filled +0.145198
108
+ seg_4 × tok:filled -0.056520
109
+ seg_1 × tok:filled -0.049499
110
+ seg_6 × tok:filled +0.027488
111
+
112
+ tok:with (Shapley=-0.123359):
113
+ seg_1 × tok:with -0.171255
114
+ seg_2 × tok:with +0.101950
115
+ seg_0 × tok:with +0.045264
116
+ seg_3 × tok:with +0.027135
117
+ seg_4 × tok:with -0.026913
118
+
119
+ tok:boats (Shapley=-0.171325):
120
+ seg_3 × tok:boats +0.214681
121
+ seg_6 × tok:boats +0.152483
122
+ seg_0 × tok:boats -0.133591
123
+ seg_2 × tok:boats -0.120506
124
+ seg_1 × tok:boats +0.114882
125
+
126
+ tok:floating (Shapley=+0.194257):
127
+ seg_3 × tok:floating +0.275116
128
+ seg_0 × tok:floating +0.120704
129
+ seg_5 × tok:floating +0.076230
130
+ seg_2 × tok:floating -0.044183
131
+ seg_1 × tok:floating -0.043412
132
+
133
+ tok:in (Shapley=-0.132036):
134
+ seg_3 × tok:in -0.152741
135
+ seg_5 × tok:in -0.145348
136
+ seg_1 × tok:in -0.114416
137
+ seg_6 × tok:in -0.047324
138
+ seg_0 × tok:in +0.017775
139
+
140
+ tok:crystal (Shapley=+0.017018):
141
+ seg_5 × tok:crystal +0.055339
142
+ seg_3 × tok:crystal +0.053869
143
+ seg_4 × tok:crystal -0.039220
144
+ seg_2 × tok:crystal -0.003780
145
+ seg_6 × tok:crystal -0.000362
146
+
147
+ tok:blue (Shapley=-0.042108):
148
+ seg_0 × tok:blue +0.119849
149
+ seg_3 × tok:blue +0.054490
150
+ seg_1 × tok:blue -0.018741
151
+ seg_5 × tok:blue -0.016717
152
+ seg_4 × tok:blue +0.008088
153
+
154
+ tok:water (Shapley=+0.083010):
155
+ seg_3 × tok:water +0.292174
156
+ seg_2 × tok:water +0.090298
157
+ seg_5 × tok:water +0.057648
158
+ seg_0 × tok:water +0.036885
159
+ seg_4 × tok:water +0.033756
160
+
161
+ tok:. (Shapley=+0.173353):
162
+ seg_0 × tok:. +0.346550
163
+ seg_3 × tok:. +0.161264
164
+ seg_6 × tok:. +0.117877
165
+ seg_2 × tok:. +0.058141
166
+ seg_5 × tok:. -0.033294
results/coco_mm/coco_281447_original.png ADDED

Git LFS Details

  • SHA256: ecd1e7ce34a81f5bbff149387db0864ce4e91c7604f4c16c17f1a0f1a53a1a86
  • Pointer size: 131 Bytes
  • Size of remote file: 563 kB
results/coco_mm/coco_281447_overlay.png ADDED

Git LFS Details

  • SHA256: bbaa0244ef9d98e1be517ceb51940ce8a3e31335ec224396c15746fd5d25355d
  • Pointer size: 131 Bytes
  • Size of remote file: 589 kB
results/coco_mm/coco_281447_segmap.png ADDED

Git LFS Details

  • SHA256: 7f5ab0818757f8bb7c85465b9f7a8a7df2f377e8b8c12d536e0d1bbc9e9e6f5b
  • Pointer size: 131 Bytes
  • Size of remote file: 593 kB
results/coco_mm/coco_281447_summary.txt ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === COCO 281447 – shapley CROSS-MODAL attributions ===
2
+
3
+ Caption: Horse in fenced pasture with others grazing on grasses.
4
+
5
+ Image region values (16 regions):
6
+ seg_2 -2.278403
7
+ seg_1 -0.822974
8
+ seg_3 -0.298733
9
+ seg_0 -0.184993
10
+ seg_13 +0.134824
11
+ seg_7 -0.016471
12
+ seg_14 +0.016337
13
+ seg_10 +0.011791
14
+ seg_4 -0.011774
15
+ seg_8 -0.010643
16
+ seg_6 +0.009416
17
+ seg_9 +0.009395
18
+ seg_11 -0.004642
19
+ seg_5 -0.003971
20
+ seg_15 +0.003474
21
+ seg_12 -0.000572
22
+
23
+ Caption token values (11 tokens):
24
+ tok:horse +4.408324
25
+ tok:fen -0.224529
26
+ tok:. -0.050243
27
+ tok:pasture -0.049678
28
+ tok:with +0.040849
29
+ tok:in +0.033160
30
+ tok:ced -0.029820
31
+ tok:grazing +0.022595
32
+ tok:others +0.008450
33
+ tok:on +0.007305
34
+ tok:grasses +0.001741
35
+
36
+ Image×Image interactions (15):
37
+ seg_1 × seg_2 +1.362463
38
+ seg_0 × seg_1 +0.532141
39
+ seg_0 × seg_3 +0.282670
40
+ seg_0 × seg_2 +0.277382
41
+ seg_1 × seg_13 +0.189160
42
+ seg_1 × seg_3 +0.103962
43
+ seg_2 × seg_6 +0.072289
44
+ seg_1 × seg_9 -0.052994
45
+ seg_1 × seg_4 -0.033681
46
+ seg_0 × seg_9 +0.033601
47
+ seg_2 × seg_9 +0.028185
48
+ seg_11 × seg_13 -0.025794
49
+ seg_2 × seg_3 -0.015109
50
+ seg_1 × seg_14 -0.015034
51
+ seg_7 × seg_11 -0.014243
52
+
53
+ Token×Token interactions (15):
54
+ tok:horse × tok:fen -0.446448
55
+ tok:horse × tok:. -0.108351
56
+ tok:horse × tok:in +0.086563
57
+ tok:horse × tok:pasture -0.065111
58
+ tok:horse × tok:grazing +0.051056
59
+ tok:horse × tok:ced -0.040790
60
+ tok:horse × tok:with +0.035492
61
+ tok:ced × tok:grazing -0.024138
62
+ tok:with × tok:others +0.022160
63
+ tok:horse × tok:others -0.018359
64
+ tok:in × tok:grasses +0.016346
65
+ tok:ced × tok:others +0.016202
66
+ tok:ced × tok:with +0.014719
67
+ tok:others × tok:on +0.013668
68
+ tok:ced × tok:pasture -0.013105
69
+
70
+ Top-5 Cross-Modal interactions (seg × tok):
71
+ seg_1 × tok:horse +2.256318
72
+ seg_2 × tok:horse -2.093609
73
+ seg_0 × tok:horse -0.486339
74
+ seg_3 × tok:horse -0.256331
75
+ seg_13 × tok:horse +0.089891
76
+
77
+ ★ Positive token #1: tok:horse (+4.408324) × top-3 segments:
78
+ seg_1 × tok:horse +2.256318
79
+ seg_2 × tok:horse -2.093609
80
+ seg_0 × tok:horse -0.486339
81
+
82
+ ★ Positive token #2: tok:with (+0.040849) × top-3 segments:
83
+ seg_9 × tok:with +0.016746
84
+ seg_10 × tok:with -0.010291
85
+ seg_15 × tok:with +0.004752
86
+
87
+ ★ Negative token #1: tok:fen (-0.224529) × top-3 segments:
88
+ seg_7 × tok:fen -0.025468
89
+ seg_5 × tok:fen +0.011604
90
+ seg_10 × tok:fen +0.007026
91
+
92
+ ★ Negative token #2: tok:. (-0.050243) × top-3 segments:
93
+ seg_14 × tok:. +0.010019
94
+ seg_3 × tok:. -0.003891
95
+ seg_4 × tok:. -0.003789
96
+
97
+ ============================================================
98
+ Per-token cross-modal interactions (top-5 each):
99
+
100
+ tok:horse (Shapley=+4.408324):
101
+ seg_1 × tok:horse +2.256318
102
+ seg_2 × tok:horse -2.093609
103
+ seg_0 × tok:horse -0.486339
104
+ seg_3 × tok:horse -0.256331
105
+ seg_13 × tok:horse +0.089891
106
+
107
+ tok:in (Shapley=+0.033160):
108
+ seg_5 × tok:in -0.015456
109
+ seg_14 × tok:in +0.007851
110
+ seg_13 × tok:in +0.007115
111
+ seg_2 × tok:in -0.006608
112
+ seg_6 × tok:in -0.004884
113
+
114
+ tok:fen (Shapley=-0.224529):
115
+ seg_7 × tok:fen -0.025468
116
+ seg_5 × tok:fen +0.011604
117
+ seg_10 × tok:fen +0.007026
118
+ seg_6 × tok:fen +0.005401
119
+ seg_2 × tok:fen -0.004163
120
+
121
+ tok:ced (Shapley=-0.029820):
122
+ seg_5 × tok:ced +0.017460
123
+ seg_8 × tok:ced -0.006840
124
+ seg_15 × tok:ced +0.006074
125
+ seg_9 × tok:ced -0.006071
126
+ seg_14 × tok:ced -0.005179
127
+
128
+ tok:pasture (Shapley=-0.049678):
129
+ seg_12 × tok:pasture -0.011237
130
+ seg_15 × tok:pasture -0.010195
131
+ seg_4 × tok:pasture +0.006291
132
+ seg_0 × tok:pasture +0.005938
133
+ seg_2 × tok:pasture -0.003224
134
+
135
+ tok:with (Shapley=+0.040849):
136
+ seg_9 × tok:with +0.016746
137
+ seg_10 × tok:with -0.010291
138
+ seg_15 × tok:with +0.004752
139
+ seg_2 × tok:with -0.002544
140
+ seg_6 × tok:with +0.002417
141
+
142
+ tok:others (Shapley=+0.008450):
143
+ seg_1 × tok:others -0.015305
144
+ seg_15 × tok:others +0.014182
145
+ seg_6 × tok:others +0.009771
146
+ seg_10 × tok:others -0.009235
147
+ seg_3 × tok:others -0.009101
148
+
149
+ tok:grazing (Shapley=+0.022595):
150
+ seg_5 × tok:grazing -0.007292
151
+ seg_10 × tok:grazing +0.004208
152
+ seg_11 × tok:grazing +0.004152
153
+ seg_13 × tok:grazing +0.003173
154
+ seg_15 × tok:grazing -0.002181
155
+
156
+ tok:on (Shapley=+0.007305):
157
+ seg_14 × tok:on +0.003634
158
+ seg_1 × tok:on +0.003621
159
+ seg_9 × tok:on -0.003367
160
+ seg_13 × tok:on +0.001808
161
+ seg_5 × tok:on -0.000969
162
+
163
+ tok:grasses (Shapley=+0.001741):
164
+ seg_4 × tok:grasses +0.010303
165
+ seg_6 × tok:grasses -0.009457
166
+ seg_5 × tok:grasses +0.005944
167
+ seg_10 × tok:grasses +0.004237
168
+ seg_15 × tok:grasses +0.003841
169
+
170
+ tok:. (Shapley=-0.050243):
171
+ seg_14 × tok:. +0.010019
172
+ seg_3 × tok:. -0.003891
173
+ seg_4 × tok:. -0.003789
174
+ seg_5 × tok:. +0.003730
175
+ seg_11 × tok:. +0.003199
results/coco_mm/coco_307172_original.png ADDED

Git LFS Details

  • SHA256: 88f338a3c3e12c34ce0247c7f8f2d0b0573ce4b3f9b6efd0d10ac32a12266b6d
  • Pointer size: 131 Bytes
  • Size of remote file: 478 kB
results/coco_mm/coco_307172_overlay.png ADDED

Git LFS Details

  • SHA256: f22c7e81b3de98bd7c96901bd17472c9ff69fec54c85a221eeb5847862a08009
  • Pointer size: 131 Bytes
  • Size of remote file: 544 kB
results/coco_mm/coco_307172_segmap.png ADDED

Git LFS Details

  • SHA256: aadd7ee4aaf4a0f9b85ebbef845f5ef0380a9c68e350786e85ba82df91617415
  • Pointer size: 131 Bytes
  • Size of remote file: 549 kB
results/coco_mm/coco_307172_summary.txt ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === COCO 307172 – shapley CROSS-MODAL attributions ===
2
+
3
+ Caption: A baked dish on a plate being touched by a woman.
4
+
5
+ Image region values (16 regions):
6
+ seg_2 +0.136766
7
+ seg_12 +0.125225
8
+ seg_0 +0.114294
9
+ seg_1 +0.088119
10
+ seg_11 +0.085390
11
+ seg_8 -0.084003
12
+ seg_7 -0.081940
13
+ seg_6 -0.051395
14
+ seg_9 +0.046654
15
+ seg_5 +0.045622
16
+ seg_10 +0.023971
17
+ seg_3 +0.020047
18
+ seg_13 -0.019814
19
+ seg_14 -0.017885
20
+ seg_4 -0.012482
21
+ seg_15 -0.001890
22
+
23
+ Caption token values (12 tokens):
24
+ tok:a +2.176333
25
+ tok:baked +0.402312
26
+ tok:dish +0.027537
27
+ tok:on -0.021476
28
+ tok:a +0.018718
29
+ tok:a -0.015789
30
+ tok:. +0.013208
31
+ tok:woman +0.013079
32
+ tok:touched +0.009330
33
+ tok:being +0.009256
34
+ tok:by +0.007656
35
+ tok:plate -0.000493
36
+
37
+ Image×Image interactions (15):
38
+ seg_1 × seg_11 +0.103462
39
+ seg_0 × seg_1 -0.060013
40
+ seg_0 × seg_2 -0.058313
41
+ seg_2 × seg_6 +0.051352
42
+ seg_0 × seg_8 -0.044922
43
+ seg_0 × seg_11 +0.033494
44
+ seg_0 × seg_12 +0.033359
45
+ seg_2 × seg_11 -0.030257
46
+ seg_7 × seg_8 -0.022596
47
+ seg_1 × seg_7 +0.021836
48
+ seg_7 × seg_11 -0.021643
49
+ seg_9 × seg_10 +0.019139
50
+ seg_2 × seg_7 +0.018743
51
+ seg_5 × seg_7 -0.018698
52
+ seg_9 × seg_12 +0.018567
53
+
54
+ Token×Token interactions (15):
55
+ tok:a × tok:baked +0.749371
56
+ tok:a × tok:dish +0.044180
57
+ tok:baked × tok:a -0.038298
58
+ tok:baked × tok:dish +0.027276
59
+ tok:a × tok:on -0.018810
60
+ tok:by × tok:. +0.017175
61
+ tok:a × tok:woman -0.016783
62
+ tok:dish × tok:woman +0.013511
63
+ tok:being × tok:by +0.012118
64
+ tok:a × tok:being +0.011113
65
+ tok:a × tok:by +0.010688
66
+ tok:plate × tok:being +0.010307
67
+ tok:baked × tok:woman +0.010033
68
+ tok:plate × tok:. -0.009749
69
+ tok:baked × tok:plate -0.009563
70
+
71
+ Top-5 Cross-Modal interactions (seg × tok):
72
+ seg_7 × tok:a +0.059859
73
+ seg_11 × tok:a -0.058050
74
+ seg_6 × tok:a +0.053917
75
+ seg_5 × tok:a +0.052764
76
+ seg_2 × tok:a +0.032056
77
+
78
+ ★ Positive token #1: tok:a (+2.176333) × top-3 segments:
79
+ seg_7 × tok:a +0.059859
80
+ seg_11 × tok:a -0.058050
81
+ seg_6 × tok:a +0.053917
82
+
83
+ ★ Positive token #2: tok:baked (+0.402312) × top-3 segments:
84
+ seg_11 × tok:baked +0.030477
85
+ seg_12 × tok:baked +0.011163
86
+ seg_3 × tok:baked +0.010498
87
+
88
+ ★ Negative token #1: tok:on (-0.021476) × top-3 segments:
89
+ seg_0 × tok:on -0.010448
90
+ seg_11 × tok:on +0.008560
91
+ seg_5 × tok:on +0.008531
92
+
93
+ ★ Negative token #2: tok:a (-0.015789) × top-3 segments:
94
+ seg_6 × tok:a -0.010533
95
+ seg_3 × tok:a +0.007261
96
+ seg_15 × tok:a +0.006404
97
+
98
+ ============================================================
99
+ Per-token cross-modal interactions (top-5 each):
100
+
101
+ tok:a (Shapley=+2.176333):
102
+ seg_7 × tok:a +0.059859
103
+ seg_11 × tok:a -0.058050
104
+ seg_6 × tok:a +0.053917
105
+ seg_5 × tok:a +0.052764
106
+ seg_2 × tok:a +0.032056
107
+
108
+ tok:baked (Shapley=+0.402312):
109
+ seg_11 × tok:baked +0.030477
110
+ seg_12 × tok:baked +0.011163
111
+ seg_3 × tok:baked +0.010498
112
+ seg_9 × tok:baked +0.008587
113
+ seg_13 × tok:baked +0.008037
114
+
115
+ tok:dish (Shapley=+0.027537):
116
+ seg_6 × tok:dish +0.018977
117
+ seg_0 × tok:dish -0.014123
118
+ seg_9 × tok:dish +0.011310
119
+ seg_7 × tok:dish -0.010339
120
+ seg_14 × tok:dish -0.008715
121
+
122
+ tok:on (Shapley=-0.021476):
123
+ seg_0 × tok:on -0.010448
124
+ seg_11 × tok:on +0.008560
125
+ seg_5 × tok:on +0.008531
126
+ seg_3 × tok:on -0.008124
127
+ seg_4 × tok:on -0.007208
128
+
129
+ tok:a (Shapley=-0.015789):
130
+ seg_6 × tok:a -0.010533
131
+ seg_3 × tok:a +0.007261
132
+ seg_15 × tok:a +0.006404
133
+ seg_0 × tok:a -0.004502
134
+ seg_13 × tok:a -0.004371
135
+
136
+ tok:plate (Shapley=-0.000493):
137
+ seg_9 × tok:plate -0.005997
138
+ seg_11 × tok:plate +0.005358
139
+ seg_1 × tok:plate -0.004499
140
+ seg_13 × tok:plate -0.004154
141
+ seg_0 × tok:plate +0.003897
142
+
143
+ tok:being (Shapley=+0.009256):
144
+ seg_15 × tok:being +0.010570
145
+ seg_11 × tok:being +0.009509
146
+ seg_1 × tok:being -0.008265
147
+ seg_0 × tok:being -0.007735
148
+ seg_8 × tok:being -0.006427
149
+
150
+ tok:touched (Shapley=+0.009330):
151
+ seg_9 × tok:touched +0.009475
152
+ seg_5 × tok:touched +0.009189
153
+ seg_11 × tok:touched +0.009155
154
+ seg_7 × tok:touched -0.005751
155
+ seg_1 × tok:touched -0.004349
156
+
157
+ tok:by (Shapley=+0.007656):
158
+ seg_15 × tok:by +0.014428
159
+ seg_13 × tok:by -0.006557
160
+ seg_9 × tok:by -0.005631
161
+ seg_6 × tok:by +0.005500
162
+ seg_1 × tok:by +0.004970
163
+
164
+ tok:a (Shapley=+0.018718):
165
+ seg_5 × tok:a +0.016070
166
+ seg_11 × tok:a +0.009449
167
+ seg_3 × tok:a -0.008144
168
+ seg_7 × tok:a -0.005613
169
+ seg_0 × tok:a +0.003870
170
+
171
+ tok:woman (Shapley=+0.013079):
172
+ seg_8 × tok:woman +0.018431
173
+ seg_11 × tok:woman +0.011556
174
+ seg_3 × tok:woman -0.008443
175
+ seg_5 × tok:woman +0.007948
176
+ seg_14 × tok:woman -0.007359
177
+
178
+ tok:. (Shapley=+0.013208):
179
+ seg_7 × tok:. -0.014178
180
+ seg_9 × tok:. +0.008633
181
+ seg_15 × tok:. +0.005227
182
+ seg_8 × tok:. +0.004485
183
+ seg_13 × tok:. +0.004019
results/coco_mm/coco_448256_original.png ADDED

Git LFS Details

  • SHA256: a184a457d2708bc0d031b37788801348881c5ea61e4c99fcd1758c06201a6f8b
  • Pointer size: 131 Bytes
  • Size of remote file: 586 kB
results/coco_mm/coco_448256_overlay.png ADDED

Git LFS Details

  • SHA256: 6d6e37fc60fcfd6c0d4b1af5e67aeae3fdecacea524e911d239e9eda0598ccaf
  • Pointer size: 131 Bytes
  • Size of remote file: 655 kB
results/coco_mm/coco_448256_segmap.png ADDED

Git LFS Details

  • SHA256: 146f949935473d9361ffc0e16ce326409f9cf19882d433fbf39946c895295b82
  • Pointer size: 131 Bytes
  • Size of remote file: 653 kB
results/coco_mm/coco_448256_summary.txt ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === COCO 448256 – shapley CROSS-MODAL attributions ===
2
+
3
+ Caption: Three men stand next to a car with its hood open.
4
+
5
+ Image region values (16 regions):
6
+ seg_1 -0.154752
7
+ seg_3 +0.133889
8
+ seg_7 +0.097503
9
+ seg_12 +0.087315
10
+ seg_9 +0.072469
11
+ seg_6 +0.068672
12
+ seg_2 -0.055121
13
+ seg_0 +0.048846
14
+ seg_10 +0.047620
15
+ seg_14 -0.041853
16
+ seg_13 +0.035891
17
+ seg_8 -0.031498
18
+ seg_15 -0.022439
19
+ seg_5 -0.006506
20
+ seg_11 -0.003508
21
+ seg_4 +0.001179
22
+
23
+ Caption token values (12 tokens):
24
+ tok:three +1.979697
25
+ tok:men +0.919549
26
+ tok:to +0.071413
27
+ tok:car +0.028613
28
+ tok:stand +0.028609
29
+ tok:open -0.025885
30
+ tok:hood +0.022758
31
+ tok:with -0.020605
32
+ tok:a +0.019752
33
+ tok:next +0.017291
34
+ tok:. -0.012653
35
+ tok:its +0.011887
36
+
37
+ Image×Image interactions (15):
38
+ seg_2 × seg_4 +0.190486
39
+ seg_1 × seg_6 +0.102403
40
+ seg_5 × seg_8 +0.077659
41
+ seg_2 × seg_14 +0.074111
42
+ seg_8 × seg_15 -0.073519
43
+ seg_1 × seg_10 -0.066988
44
+ seg_2 × seg_6 +0.060852
45
+ seg_9 × seg_12 +0.054643
46
+ seg_3 × seg_5 +0.054639
47
+ seg_1 × seg_14 -0.054518
48
+ seg_3 × seg_4 +0.048830
49
+ seg_12 × seg_14 +0.048132
50
+ seg_6 × seg_10 -0.047251
51
+ seg_1 × seg_2 +0.046458
52
+ seg_1 × seg_3 +0.045791
53
+
54
+ Token×Token interactions (15):
55
+ tok:three × tok:men +1.803450
56
+ tok:next × tok:car +0.068161
57
+ tok:stand × tok:car +0.059719
58
+ tok:next × tok:its +0.054150
59
+ tok:next × tok:to +0.039551
60
+ tok:open × tok:. -0.037103
61
+ tok:to × tok:its -0.027991
62
+ tok:three × tok:stand -0.026706
63
+ tok:three × tok:a +0.025264
64
+ tok:three × tok:its +0.023843
65
+ tok:stand × tok:to +0.018563
66
+ tok:three × tok:open -0.018217
67
+ tok:car × tok:its +0.016328
68
+ tok:car × tok:open -0.015769
69
+ tok:a × tok:car +0.014476
70
+
71
+ Top-5 Cross-Modal interactions (seg × tok):
72
+ seg_8 × tok:three +0.198546
73
+ seg_5 × tok:three +0.179486
74
+ seg_10 × tok:three -0.141457
75
+ seg_1 × tok:three +0.132898
76
+ seg_14 × tok:three +0.090481
77
+
78
+ ★ Positive token #1: tok:three (+1.979697) × top-3 segments:
79
+ seg_8 × tok:three +0.198546
80
+ seg_5 × tok:three +0.179486
81
+ seg_10 × tok:three -0.141457
82
+
83
+ ★ Positive token #2: tok:men (+0.919549) × top-3 segments:
84
+ seg_11 × tok:men +0.080520
85
+ seg_8 × tok:men -0.041843
86
+ seg_3 × tok:men -0.022314
87
+
88
+ ★ Negative token #1: tok:open (-0.025885) × top-3 segments:
89
+ seg_7 × tok:open +0.050209
90
+ seg_8 × tok:open +0.038709
91
+ seg_14 × tok:open +0.031120
92
+
93
+ ★ Negative token #2: tok:with (-0.020605) × top-3 segments:
94
+ seg_2 × tok:with -0.050356
95
+ seg_9 × tok:with -0.028777
96
+ seg_7 × tok:with -0.026861
97
+
98
+ ============================================================
99
+ Per-token cross-modal interactions (top-5 each):
100
+
101
+ tok:three (Shapley=+1.979697):
102
+ seg_8 × tok:three +0.198546
103
+ seg_5 × tok:three +0.179486
104
+ seg_10 × tok:three -0.141457
105
+ seg_1 × tok:three +0.132898
106
+ seg_14 × tok:three +0.090481
107
+
108
+ tok:men (Shapley=+0.919549):
109
+ seg_11 × tok:men +0.080520
110
+ seg_8 × tok:men -0.041843
111
+ seg_3 × tok:men -0.022314
112
+ seg_5 × tok:men -0.016305
113
+ seg_7 × tok:men -0.012643
114
+
115
+ tok:stand (Shapley=+0.028609):
116
+ seg_3 × tok:stand +0.015101
117
+ seg_7 × tok:stand -0.009797
118
+ seg_8 × tok:stand -0.004832
119
+ seg_15 × tok:stand -0.000945
120
+ seg_9 × tok:stand +0.000807
121
+
122
+ tok:next (Shapley=+0.017291):
123
+ seg_9 × tok:next +0.031625
124
+ seg_13 × tok:next +0.004279
125
+ seg_14 × tok:next +0.000663
126
+
127
+ tok:to (Shapley=+0.071413):
128
+ seg_2 × tok:to +0.070109
129
+ seg_3 × tok:to -0.038082
130
+ seg_5 × tok:to +0.030661
131
+ seg_7 × tok:to +0.026240
132
+ seg_10 × tok:to +0.014946
133
+
134
+ tok:a (Shapley=+0.019752):
135
+ seg_9 × tok:a +0.016140
136
+ seg_13 × tok:a +0.011941
137
+ seg_2 × tok:a +0.005905
138
+ seg_10 × tok:a -0.004813
139
+
140
+ tok:car (Shapley=+0.028613):
141
+ seg_10 × tok:car +0.031554
142
+ seg_0 × tok:car +0.010387
143
+ seg_9 × tok:car +0.007219
144
+
145
+ tok:with (Shapley=-0.020605):
146
+ seg_2 × tok:with -0.050356
147
+ seg_9 × tok:with -0.028777
148
+ seg_7 × tok:with -0.026861
149
+ seg_11 × tok:with -0.023846
150
+ seg_3 × tok:with -0.018209
151
+
152
+ tok:its (Shapley=+0.011887):
153
+ seg_0 × tok:its +0.022133
154
+ seg_3 × tok:its -0.015795
155
+ seg_1 × tok:its -0.015457
156
+ seg_10 × tok:its -0.007296
157
+ seg_8 × tok:its -0.006122
158
+
159
+ tok:hood (Shapley=+0.022758):
160
+ seg_9 × tok:hood +0.042906
161
+ seg_14 × tok:hood -0.016583
162
+ seg_2 × tok:hood -0.013351
163
+ seg_0 × tok:hood -0.006368
164
+ seg_6 × tok:hood -0.005324
165
+
166
+ tok:open (Shapley=-0.025885):
167
+ seg_7 × tok:open +0.050209
168
+ seg_8 × tok:open +0.038709
169
+ seg_14 × tok:open +0.031120
170
+ seg_9 × tok:open +0.009955
171
+ seg_13 × tok:open +0.007836
172
+
173
+ tok:. (Shapley=-0.012653):
174
+ seg_10 × tok:. +0.020455
175
+ seg_11 × tok:. +0.010527
176
+ seg_2 × tok:. +0.008770
results/coco_mm/coco_56350_original.png ADDED

Git LFS Details

  • SHA256: 2425ec7c909cc3e33ddef1b27221804a1862e42acdcd970a7e62f943aa4bb695
  • Pointer size: 131 Bytes
  • Size of remote file: 489 kB