Qingpeng Kong commited on
Commit ·
3e72399
0
Parent(s):
clean initial state
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +41 -0
- .gitignore +3 -0
- README.md +41 -0
- app.py +5 -0
- attribution/__init__.py +0 -0
- attribution/attribution.py +1262 -0
- attribution/image_masker.py +182 -0
- attribution/masker.py +149 -0
- attribution/mm_game.py +922 -0
- attribution/proxyspex.py +311 -0
- attribution/set_mm.py +2397 -0
- attribution/utils.py +438 -0
- audioop.py +31 -0
- datasets/BBQ_disamb.csv +3 -0
- datasets/BarExam_qa.csv +3 -0
- datasets/CNN_dailymail.csv +3 -0
- datasets/bbh_causal_judgement.csv +3 -0
- datasets/bbh_snarks.csv +3 -0
- datasets/drop.csv +3 -0
- datasets/eSNLI.csv +3 -0
- datasets/fever.csv +3 -0
- datasets/hotpot_qa.csv +3 -0
- datasets/medical_qa.csv +3 -0
- loader/__init__.py +84 -0
- loader/data.py +179 -0
- loader/models.py +65 -0
- loader/models_mm_hf.py +252 -0
- loader/models_mm_vllm.py +165 -0
- loader/models_vllm.py +218 -0
- loader/perplexity.py +538 -0
- loader/results.py +468 -0
- loader/utils.py +42 -0
- requirements.txt +11 -0
- results/coco_mm/coco_133233_original.png +3 -0
- results/coco_mm/coco_133233_overlay.png +3 -0
- results/coco_mm/coco_133233_segmap.png +3 -0
- results/coco_mm/coco_133233_summary.txt +166 -0
- results/coco_mm/coco_281447_original.png +3 -0
- results/coco_mm/coco_281447_overlay.png +3 -0
- results/coco_mm/coco_281447_segmap.png +3 -0
- results/coco_mm/coco_281447_summary.txt +175 -0
- results/coco_mm/coco_307172_original.png +3 -0
- results/coco_mm/coco_307172_overlay.png +3 -0
- results/coco_mm/coco_307172_segmap.png +3 -0
- results/coco_mm/coco_307172_summary.txt +183 -0
- results/coco_mm/coco_448256_original.png +3 -0
- results/coco_mm/coco_448256_overlay.png +3 -0
- results/coco_mm/coco_448256_segmap.png +3 -0
- results/coco_mm/coco_448256_summary.txt +176 -0
- 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
|
results/coco_mm/coco_133233_overlay.png
ADDED
|
Git LFS Details
|
results/coco_mm/coco_133233_segmap.png
ADDED
|
Git LFS Details
|
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
|
results/coco_mm/coco_281447_overlay.png
ADDED
|
Git LFS Details
|
results/coco_mm/coco_281447_segmap.png
ADDED
|
Git LFS Details
|
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
|
results/coco_mm/coco_307172_overlay.png
ADDED
|
Git LFS Details
|
results/coco_mm/coco_307172_segmap.png
ADDED
|
Git LFS Details
|
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
|
results/coco_mm/coco_448256_overlay.png
ADDED
|
Git LFS Details
|
results/coco_mm/coco_448256_segmap.png
ADDED
|
Git LFS Details
|
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
|