Fix Task 1 root layout
Browse files- .gitattributes +35 -35
- README.md +75 -0
- checkpoints/last.pt +3 -0
- requirements.txt +12 -0
- src/__init__.py +1 -0
- src/alignment/__init__.py +0 -0
- src/alignment/__pycache__/__init__.cpython-311.pyc +0 -0
- src/alignment/__pycache__/sinkhorn_ot.cpython-311.pyc +0 -0
- src/alignment/sinkhorn_ot.py +237 -0
- src/models/__init__.py +0 -0
- src/models/__pycache__/__init__.cpython-311.pyc +0 -0
- src/models/__pycache__/fusion.cpython-311.pyc +0 -0
- src/models/__pycache__/structural_vqa.cpython-311.pyc +0 -0
- src/models/__pycache__/structural_vqa_generative.cpython-311.pyc +0 -0
- src/models/__pycache__/structural_vqa_generative.cpython-314.pyc +0 -0
- src/models/__pycache__/text_encoder.cpython-311.pyc +0 -0
- src/models/__pycache__/vision_encoder.cpython-311.pyc +0 -0
- src/models/fusion.py +269 -0
- src/models/llm_decoder.py +0 -0
- src/models/structural_vqa.py +474 -0
- src/models/structural_vqa_generative.py +457 -0
- src/models/text_encoder.py +281 -0
- src/models/vision_encoder.py +347 -0
- src/postprocessing/__init__.py +5 -0
- src/postprocessing/answer_normalization.py +41 -0
- submission_task1.py +256 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,35 @@
|
|
| 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
|
|
|
|
| 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
|
README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
tags:
|
| 4 |
+
- medical-vqa
|
| 5 |
+
- gastroenterology
|
| 6 |
+
- endoscopy
|
| 7 |
+
- mediaeval
|
| 8 |
+
- medico-2026
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# MediaEval Medico 2026 Task 1 - Qwen3B A3 Structural VQA
|
| 12 |
+
|
| 13 |
+
This repository contains a Task 1 submission for **MediaEval Medico 2026: Medical Image Question Answering in GI Endoscopy**.
|
| 14 |
+
|
| 15 |
+
## Model
|
| 16 |
+
|
| 17 |
+
- Base language model: `Qwen/Qwen2.5-3B-Instruct`
|
| 18 |
+
- Architecture: Structural VQA with A3 prior OT fusion
|
| 19 |
+
- Adaptation: LoRA, rank 16, alpha 32
|
| 20 |
+
- Vision backbone: timm-based encoder used by the project code
|
| 21 |
+
- Output: concise clinical answer for GI endoscopy VQA
|
| 22 |
+
|
| 23 |
+
## Training Summary
|
| 24 |
+
|
| 25 |
+
Current confirmed final fallback:
|
| 26 |
+
|
| 27 |
+
```text
|
| 28 |
+
Run B fixed: best 30k r16 epoch2 -> full original polish lr5e-6
|
| 29 |
+
Full-test token_f1: 0.604086
|
| 30 |
+
Checkpoint: checkpoints/final.pt
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Key inference/training constraints:
|
| 34 |
+
|
| 35 |
+
- Physical batch size: 1
|
| 36 |
+
- Gradient accumulation during training: 8
|
| 37 |
+
- Evaluation batch size: 1
|
| 38 |
+
- Scheduler: cosine
|
| 39 |
+
- LoRA rank: 16
|
| 40 |
+
- Final model selection is based on full-test evaluation.
|
| 41 |
+
|
| 42 |
+
## Post-processing
|
| 43 |
+
|
| 44 |
+
Inference applies minimal answer cleanup only:
|
| 45 |
+
|
| 46 |
+
- strip leading/trailing whitespace
|
| 47 |
+
- compact repeated spaces
|
| 48 |
+
- remove a leading `Answer:` prefix if generated
|
| 49 |
+
- preserve medical wording and semantic content
|
| 50 |
+
|
| 51 |
+
Aggressive qtype-aware normalization is intentionally disabled because full-test rescoring showed it can reduce Task 1 metrics.
|
| 52 |
+
|
| 53 |
+
The normalizer does **not** use ground-truth labels.
|
| 54 |
+
|
| 55 |
+
## Inference
|
| 56 |
+
|
| 57 |
+
The official entry point is:
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
python submission_task1.py
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
The script is designed to be validated with:
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
medvqa validate --competition=medico-2026 --task=1 --repo_id=minhquang47/medico2026-task1-qwen3b-a3
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
## Notes
|
| 70 |
+
|
| 71 |
+
This repository is for Task 1 answer generation only. Explanations and visual evidence are handled in the separate Task 2 repository:
|
| 72 |
+
|
| 73 |
+
```text
|
| 74 |
+
minhquang47/medico2026-task2-explanations
|
| 75 |
+
```
|
checkpoints/last.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1394f7654967b22b7ac5bf07b918d704986010c654672c8e03b8245aa2355199
|
| 3 |
+
size 228303929
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
transformers
|
| 3 |
+
peft
|
| 4 |
+
timm
|
| 5 |
+
accelerate
|
| 6 |
+
sentencepiece
|
| 7 |
+
protobuf
|
| 8 |
+
Pillow
|
| 9 |
+
numpy
|
| 10 |
+
pandas
|
| 11 |
+
scikit-learn
|
| 12 |
+
tqdm
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Minimal package copy for the HuggingFace Task 1 submission.
|
src/alignment/__init__.py
ADDED
|
File without changes
|
src/alignment/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (173 Bytes). View file
|
|
|
src/alignment/__pycache__/sinkhorn_ot.cpython-311.pyc
ADDED
|
Binary file (13.2 kB). View file
|
|
|
src/alignment/sinkhorn_ot.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Dict, Optional
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class SinkhornOTConfig:
|
| 11 |
+
"""
|
| 12 |
+
Cấu hình Sinkhorn Optimal Transport Router.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
epsilon: Entropic regularization. Nhỏ hơn -> transport sắc hơn nhưng dễ unstable.
|
| 16 |
+
num_iters: Số vòng Sinkhorn normalization.
|
| 17 |
+
cost_type: "cosine" hoặc "euclidean".
|
| 18 |
+
normalize_embeddings: L2 normalize embeddings trước khi tính cosine cost.
|
| 19 |
+
reduction: "mean", "sum", hoặc "none" cho ot_cost.
|
| 20 |
+
eps: Số nhỏ tránh chia log(0).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
epsilon: float = 0.07
|
| 24 |
+
num_iters: int = 50
|
| 25 |
+
cost_type: str = "cosine"
|
| 26 |
+
normalize_embeddings: bool = True
|
| 27 |
+
reduction: str = "mean"
|
| 28 |
+
eps: float = 1e-8
|
| 29 |
+
|
| 30 |
+
def __post_init__(self):
|
| 31 |
+
if self.epsilon <= 0:
|
| 32 |
+
raise ValueError("epsilon phải > 0")
|
| 33 |
+
if self.num_iters <= 0:
|
| 34 |
+
raise ValueError("num_iters phải > 0")
|
| 35 |
+
if self.cost_type not in {"cosine", "euclidean"}:
|
| 36 |
+
raise ValueError("cost_type phải là 'cosine' hoặc 'euclidean'")
|
| 37 |
+
if self.reduction not in {"mean", "sum", "none"}:
|
| 38 |
+
raise ValueError("reduction phải là 'mean', 'sum', hoặc 'none'")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class SinkhornOTRouter(nn.Module):
|
| 42 |
+
"""
|
| 43 |
+
Cross-modal alignment bằng Sinkhorn Optimal Transport.
|
| 44 |
+
|
| 45 |
+
Input:
|
| 46 |
+
visual_tokens: [B, Nv, D]
|
| 47 |
+
text_tokens: [B, Nt, D]
|
| 48 |
+
visual_mask: [B, Nv] optional, 1=valid, 0=pad
|
| 49 |
+
text_mask: [B, Nt] optional, 1=valid, 0=pad
|
| 50 |
+
|
| 51 |
+
Output:
|
| 52 |
+
transport_plan: [B, Nt, Nv]
|
| 53 |
+
cost_matrix: [B, Nt, Nv]
|
| 54 |
+
aligned_visual: [B, Nt, D]
|
| 55 |
+
ot_cost: scalar hoặc [B]
|
| 56 |
+
|
| 57 |
+
Ý nghĩa:
|
| 58 |
+
Mỗi text token học cách "vận tải attention" tới các visual tokens phù hợp.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def __init__(self, config: Optional[SinkhornOTConfig] = None):
|
| 62 |
+
super().__init__()
|
| 63 |
+
self.config = config or SinkhornOTConfig()
|
| 64 |
+
|
| 65 |
+
def forward(
|
| 66 |
+
self,
|
| 67 |
+
visual_tokens: torch.Tensor,
|
| 68 |
+
text_tokens: torch.Tensor,
|
| 69 |
+
visual_mask: Optional[torch.Tensor] = None,
|
| 70 |
+
text_mask: Optional[torch.Tensor] = None,
|
| 71 |
+
visual_target_mass: Optional[torch.Tensor] = None,
|
| 72 |
+
return_diagnostics: bool = True,
|
| 73 |
+
):
|
| 74 |
+
self._validate_inputs(visual_tokens, text_tokens)
|
| 75 |
+
batch_size, num_visual, d_model = visual_tokens.shape
|
| 76 |
+
_, num_text, _ = text_tokens.shape
|
| 77 |
+
|
| 78 |
+
visual_mask = self._prepare_mask(
|
| 79 |
+
visual_mask,
|
| 80 |
+
batch_size=batch_size,
|
| 81 |
+
length=num_visual,
|
| 82 |
+
device=visual_tokens.device,
|
| 83 |
+
dtype=visual_tokens.dtype,
|
| 84 |
+
)
|
| 85 |
+
text_mask = self._prepare_mask(
|
| 86 |
+
text_mask,
|
| 87 |
+
batch_size=batch_size,
|
| 88 |
+
length=num_text,
|
| 89 |
+
device=text_tokens.device,
|
| 90 |
+
dtype=text_tokens.dtype,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
visual_target_mass = self._prepare_mask(
|
| 94 |
+
visual_target_mass,
|
| 95 |
+
batch_size=batch_size,
|
| 96 |
+
length=num_visual,
|
| 97 |
+
device=visual_tokens.device,
|
| 98 |
+
dtype=visual_tokens.dtype,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
cost_matrix = self.compute_cost_matrix(text_tokens, visual_tokens)
|
| 102 |
+
transport_plan = self.sinkhorn(
|
| 103 |
+
cost_matrix,
|
| 104 |
+
text_mask=text_mask,
|
| 105 |
+
visual_mask=visual_mask,
|
| 106 |
+
visual_target_mass=visual_target_mass,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
aligned_visual = torch.bmm(transport_plan, visual_tokens)
|
| 110 |
+
text_mass = transport_plan.sum(dim=-1, keepdim=True).clamp_min(self.config.eps)
|
| 111 |
+
aligned_visual = aligned_visual / text_mass
|
| 112 |
+
|
| 113 |
+
per_sample_cost = (transport_plan * cost_matrix).sum(dim=(1, 2))
|
| 114 |
+
ot_cost = self._reduce(per_sample_cost)
|
| 115 |
+
|
| 116 |
+
if not return_diagnostics:
|
| 117 |
+
return aligned_visual
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"transport_plan": transport_plan,
|
| 121 |
+
"cost_matrix": cost_matrix,
|
| 122 |
+
"aligned_visual": aligned_visual,
|
| 123 |
+
"ot_cost": ot_cost,
|
| 124 |
+
"per_sample_ot_cost": per_sample_cost,
|
| 125 |
+
"transport_entropy": self.transport_entropy(transport_plan),
|
| 126 |
+
"text_mass": transport_plan.sum(dim=-1),
|
| 127 |
+
"visual_mass": transport_plan.sum(dim=1),
|
| 128 |
+
"visual_target_mass": visual_target_mass,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
def compute_cost_matrix(self, text_tokens: torch.Tensor, visual_tokens: torch.Tensor) -> torch.Tensor:
|
| 132 |
+
if self.config.cost_type == "cosine":
|
| 133 |
+
text = text_tokens
|
| 134 |
+
visual = visual_tokens
|
| 135 |
+
if self.config.normalize_embeddings:
|
| 136 |
+
text = F.normalize(text, p=2, dim=-1)
|
| 137 |
+
visual = F.normalize(visual, p=2, dim=-1)
|
| 138 |
+
similarity = torch.bmm(text, visual.transpose(1, 2))
|
| 139 |
+
return 1.0 - similarity
|
| 140 |
+
|
| 141 |
+
# Euclidean squared cost: ||t - v||^2
|
| 142 |
+
text_sq = (text_tokens ** 2).sum(dim=-1, keepdim=True)
|
| 143 |
+
visual_sq = (visual_tokens ** 2).sum(dim=-1).unsqueeze(1)
|
| 144 |
+
cross = torch.bmm(text_tokens, visual_tokens.transpose(1, 2))
|
| 145 |
+
return (text_sq + visual_sq - 2.0 * cross).clamp_min(0.0)
|
| 146 |
+
|
| 147 |
+
def sinkhorn(
|
| 148 |
+
self,
|
| 149 |
+
cost_matrix: torch.Tensor,
|
| 150 |
+
text_mask: Optional[torch.Tensor] = None,
|
| 151 |
+
visual_mask: Optional[torch.Tensor] = None,
|
| 152 |
+
visual_target_mass: Optional[torch.Tensor] = None,
|
| 153 |
+
) -> torch.Tensor:
|
| 154 |
+
batch_size, num_text, num_visual = cost_matrix.shape
|
| 155 |
+
dtype = cost_matrix.dtype
|
| 156 |
+
device = cost_matrix.device
|
| 157 |
+
|
| 158 |
+
if text_mask is None:
|
| 159 |
+
text_mask = torch.ones(batch_size, num_text, device=device, dtype=dtype)
|
| 160 |
+
if visual_mask is None:
|
| 161 |
+
visual_mask = torch.ones(batch_size, num_visual, device=device, dtype=dtype)
|
| 162 |
+
|
| 163 |
+
text_mask = text_mask.to(device=device, dtype=dtype)
|
| 164 |
+
visual_mask = visual_mask.to(device=device, dtype=dtype)
|
| 165 |
+
|
| 166 |
+
source_mass = text_mask / text_mask.sum(dim=1, keepdim=True).clamp_min(self.config.eps)
|
| 167 |
+
if visual_target_mass is None:
|
| 168 |
+
target_mass = visual_mask / visual_mask.sum(dim=1, keepdim=True).clamp_min(self.config.eps)
|
| 169 |
+
else:
|
| 170 |
+
visual_target_mass = visual_target_mass.to(device=device, dtype=dtype) * visual_mask
|
| 171 |
+
target_mass = visual_target_mass / visual_target_mass.sum(dim=1, keepdim=True).clamp_min(self.config.eps)
|
| 172 |
+
|
| 173 |
+
kernel = torch.exp(-cost_matrix / self.config.epsilon)
|
| 174 |
+
valid_matrix = text_mask.unsqueeze(-1) * visual_mask.unsqueeze(1)
|
| 175 |
+
kernel = kernel * valid_matrix + self.config.eps * valid_matrix
|
| 176 |
+
|
| 177 |
+
u = torch.ones_like(source_mass)
|
| 178 |
+
v = torch.ones_like(target_mass)
|
| 179 |
+
|
| 180 |
+
for _ in range(self.config.num_iters):
|
| 181 |
+
kv = torch.bmm(kernel, v.unsqueeze(-1)).squeeze(-1).clamp_min(self.config.eps)
|
| 182 |
+
u = source_mass / kv
|
| 183 |
+
u = u * text_mask
|
| 184 |
+
|
| 185 |
+
ktu = torch.bmm(kernel.transpose(1, 2), u.unsqueeze(-1)).squeeze(-1).clamp_min(self.config.eps)
|
| 186 |
+
v = target_mass / ktu
|
| 187 |
+
v = v * visual_mask
|
| 188 |
+
|
| 189 |
+
transport_plan = u.unsqueeze(-1) * kernel * v.unsqueeze(1)
|
| 190 |
+
transport_plan = transport_plan * valid_matrix
|
| 191 |
+
return transport_plan
|
| 192 |
+
|
| 193 |
+
@staticmethod
|
| 194 |
+
def transport_entropy(transport_plan: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
|
| 195 |
+
entropy = -(transport_plan * torch.log(transport_plan.clamp_min(eps))).sum(dim=(1, 2))
|
| 196 |
+
return entropy
|
| 197 |
+
|
| 198 |
+
def _reduce(self, values: torch.Tensor) -> torch.Tensor:
|
| 199 |
+
if self.config.reduction == "mean":
|
| 200 |
+
return values.mean()
|
| 201 |
+
if self.config.reduction == "sum":
|
| 202 |
+
return values.sum()
|
| 203 |
+
return values
|
| 204 |
+
|
| 205 |
+
@staticmethod
|
| 206 |
+
def _validate_inputs(visual_tokens: torch.Tensor, text_tokens: torch.Tensor) -> None:
|
| 207 |
+
if not torch.is_tensor(visual_tokens):
|
| 208 |
+
raise TypeError("visual_tokens phải là torch.Tensor")
|
| 209 |
+
if not torch.is_tensor(text_tokens):
|
| 210 |
+
raise TypeError("text_tokens phải là torch.Tensor")
|
| 211 |
+
if visual_tokens.ndim != 3:
|
| 212 |
+
raise ValueError(f"visual_tokens phải có shape [B, Nv, D], nhận {tuple(visual_tokens.shape)}")
|
| 213 |
+
if text_tokens.ndim != 3:
|
| 214 |
+
raise ValueError(f"text_tokens phải có shape [B, Nt, D], nhận {tuple(text_tokens.shape)}")
|
| 215 |
+
if visual_tokens.shape[0] != text_tokens.shape[0]:
|
| 216 |
+
raise ValueError("Batch size visual_tokens và text_tokens không khớp")
|
| 217 |
+
if visual_tokens.shape[-1] != text_tokens.shape[-1]:
|
| 218 |
+
raise ValueError("Hidden dim visual_tokens và text_tokens không khớp")
|
| 219 |
+
|
| 220 |
+
@staticmethod
|
| 221 |
+
def _prepare_mask(
|
| 222 |
+
mask: Optional[torch.Tensor],
|
| 223 |
+
batch_size: int,
|
| 224 |
+
length: int,
|
| 225 |
+
device: torch.device,
|
| 226 |
+
dtype: torch.dtype,
|
| 227 |
+
) -> Optional[torch.Tensor]:
|
| 228 |
+
if mask is None:
|
| 229 |
+
return None
|
| 230 |
+
if not torch.is_tensor(mask):
|
| 231 |
+
mask = torch.as_tensor(mask)
|
| 232 |
+
mask = mask.to(device=device, dtype=dtype)
|
| 233 |
+
if mask.ndim != 2:
|
| 234 |
+
raise ValueError(f"mask phải có shape [B, L], nhận {tuple(mask.shape)}")
|
| 235 |
+
if mask.shape != (batch_size, length):
|
| 236 |
+
raise ValueError(f"mask shape={tuple(mask.shape)} không khớp expected={(batch_size, length)}")
|
| 237 |
+
return mask
|
src/models/__init__.py
ADDED
|
File without changes
|
src/models/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (170 Bytes). View file
|
|
|
src/models/__pycache__/fusion.cpython-311.pyc
ADDED
|
Binary file (13.2 kB). View file
|
|
|
src/models/__pycache__/structural_vqa.cpython-311.pyc
ADDED
|
Binary file (23 kB). View file
|
|
|
src/models/__pycache__/structural_vqa_generative.cpython-311.pyc
ADDED
|
Binary file (24.6 kB). View file
|
|
|
src/models/__pycache__/structural_vqa_generative.cpython-314.pyc
ADDED
|
Binary file (26.7 kB). View file
|
|
|
src/models/__pycache__/text_encoder.cpython-311.pyc
ADDED
|
Binary file (14.8 kB). View file
|
|
|
src/models/__pycache__/vision_encoder.cpython-311.pyc
ADDED
|
Binary file (17.7 kB). View file
|
|
|
src/models/fusion.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Dict, Optional, Tuple
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class StructuralFusionConfig:
|
| 10 |
+
"""
|
| 11 |
+
Cấu hình cho StructuralVisualFusion.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
d_model: Hidden dimension của visual tokens từ ViT/VLM.
|
| 15 |
+
topo_feature_dim: Số chiều feature topology/morphology mỗi patch.
|
| 16 |
+
hidden_dim: Hidden dimension của MLP chiếu topo_features -> d_model.
|
| 17 |
+
prior_strength: Hệ số spatial modulation ban đầu cho lesion prior.
|
| 18 |
+
topo_strength: Hệ số enrichment ban đầu cho topo embedding.
|
| 19 |
+
dropout: Dropout trong topo MLP.
|
| 20 |
+
use_layer_norm: Chuẩn hóa visual/topo branch để ổn định training.
|
| 21 |
+
learnable_gates: Nếu True, alpha/beta là tham số học được.
|
| 22 |
+
normalize_prior: Nếu True, normalize prior_mask về [0, 1] theo từng sample.
|
| 23 |
+
residual_scale: Scale residual cuối để tránh làm lệch mạnh pretrained tokens.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
d_model: int
|
| 27 |
+
topo_feature_dim: int
|
| 28 |
+
hidden_dim: Optional[int] = None
|
| 29 |
+
prior_strength: float = 0.30
|
| 30 |
+
topo_strength: float = 0.10
|
| 31 |
+
dropout: float = 0.10
|
| 32 |
+
use_layer_norm: bool = True
|
| 33 |
+
learnable_gates: bool = True
|
| 34 |
+
normalize_prior: bool = True
|
| 35 |
+
residual_scale: float = 1.0
|
| 36 |
+
|
| 37 |
+
def __post_init__(self):
|
| 38 |
+
if self.d_model <= 0:
|
| 39 |
+
raise ValueError("d_model phải > 0")
|
| 40 |
+
if self.topo_feature_dim <= 0:
|
| 41 |
+
raise ValueError("topo_feature_dim phải > 0")
|
| 42 |
+
if self.hidden_dim is None:
|
| 43 |
+
self.hidden_dim = max(self.d_model // 2, self.topo_feature_dim * 4)
|
| 44 |
+
if self.hidden_dim <= 0:
|
| 45 |
+
raise ValueError("hidden_dim phải > 0")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class StructuralVisualFusion(nn.Module):
|
| 49 |
+
"""
|
| 50 |
+
Fusion module nối ViT visual tokens với lesion prior và topology features.
|
| 51 |
+
|
| 52 |
+
Forward logic:
|
| 53 |
+
V_fused = V * (1 + alpha * P) + beta * MLP(F_topo)
|
| 54 |
+
|
| 55 |
+
Trong đó:
|
| 56 |
+
V: visual_tokens, shape [B, N, D]
|
| 57 |
+
P: prior_mask, shape [B, N] hoặc [B, H, W]
|
| 58 |
+
F_topo: topo_features, shape [B, N, F] hoặc [B, H, W, F]
|
| 59 |
+
|
| 60 |
+
Output chính:
|
| 61 |
+
fused_tokens, shape [B, N, D]
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
def __init__(self, config: StructuralFusionConfig):
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.config = config
|
| 67 |
+
|
| 68 |
+
self.visual_norm = nn.LayerNorm(config.d_model) if config.use_layer_norm else nn.Identity()
|
| 69 |
+
self.topo_norm = nn.LayerNorm(config.topo_feature_dim) if config.use_layer_norm else nn.Identity()
|
| 70 |
+
|
| 71 |
+
self.topo_projector = nn.Sequential(
|
| 72 |
+
nn.Linear(config.topo_feature_dim, config.hidden_dim),
|
| 73 |
+
nn.GELU(),
|
| 74 |
+
nn.Dropout(config.dropout),
|
| 75 |
+
nn.Linear(config.hidden_dim, config.d_model),
|
| 76 |
+
nn.Dropout(config.dropout),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
if config.learnable_gates:
|
| 80 |
+
self.prior_gate = nn.Parameter(torch.tensor(float(config.prior_strength)))
|
| 81 |
+
self.topo_gate = nn.Parameter(torch.tensor(float(config.topo_strength)))
|
| 82 |
+
else:
|
| 83 |
+
self.register_buffer("prior_gate", torch.tensor(float(config.prior_strength)))
|
| 84 |
+
self.register_buffer("topo_gate", torch.tensor(float(config.topo_strength)))
|
| 85 |
+
|
| 86 |
+
self.output_norm = nn.LayerNorm(config.d_model) if config.use_layer_norm else nn.Identity()
|
| 87 |
+
|
| 88 |
+
def forward(
|
| 89 |
+
self,
|
| 90 |
+
visual_tokens: torch.Tensor,
|
| 91 |
+
prior_mask: Optional[torch.Tensor] = None,
|
| 92 |
+
topo_features: Optional[torch.Tensor] = None,
|
| 93 |
+
return_diagnostics: bool = True,
|
| 94 |
+
):
|
| 95 |
+
"""
|
| 96 |
+
Args:
|
| 97 |
+
visual_tokens: Tensor [B, N, D].
|
| 98 |
+
prior_mask: Optional tensor [B, N] hoặc [B, H, W].
|
| 99 |
+
topo_features: Optional tensor [B, N, F] hoặc [B, H, W, F].
|
| 100 |
+
return_diagnostics: Nếu True trả về dict debug; nếu False chỉ trả fused_tokens.
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
dict hoặc Tensor:
|
| 104 |
+
{
|
| 105 |
+
"fused_tokens": [B, N, D],
|
| 106 |
+
"prior_weights": [B, N, 1] | None,
|
| 107 |
+
"topo_embedding": [B, N, D] | None,
|
| 108 |
+
"prior_gate": scalar,
|
| 109 |
+
"topo_gate": scalar,
|
| 110 |
+
}
|
| 111 |
+
"""
|
| 112 |
+
self._validate_visual_tokens(visual_tokens)
|
| 113 |
+
batch_size, num_tokens, d_model = visual_tokens.shape
|
| 114 |
+
|
| 115 |
+
fused_tokens = self.visual_norm(visual_tokens)
|
| 116 |
+
prior_weights = None
|
| 117 |
+
topo_embedding = None
|
| 118 |
+
|
| 119 |
+
if prior_mask is not None:
|
| 120 |
+
prior_weights = self._prepare_prior_mask(
|
| 121 |
+
prior_mask,
|
| 122 |
+
batch_size=batch_size,
|
| 123 |
+
num_tokens=num_tokens,
|
| 124 |
+
device=visual_tokens.device,
|
| 125 |
+
dtype=visual_tokens.dtype,
|
| 126 |
+
)
|
| 127 |
+
fused_tokens = fused_tokens * (1.0 + self.prior_gate * prior_weights)
|
| 128 |
+
|
| 129 |
+
if topo_features is not None:
|
| 130 |
+
topo_features = self._prepare_topo_features(
|
| 131 |
+
topo_features,
|
| 132 |
+
batch_size=batch_size,
|
| 133 |
+
num_tokens=num_tokens,
|
| 134 |
+
device=visual_tokens.device,
|
| 135 |
+
dtype=visual_tokens.dtype,
|
| 136 |
+
)
|
| 137 |
+
topo_features = self.topo_norm(topo_features)
|
| 138 |
+
topo_embedding = self.topo_projector(topo_features)
|
| 139 |
+
fused_tokens = fused_tokens + self.topo_gate * topo_embedding
|
| 140 |
+
|
| 141 |
+
if self.config.residual_scale != 1.0:
|
| 142 |
+
fused_tokens = visual_tokens + self.config.residual_scale * (fused_tokens - visual_tokens)
|
| 143 |
+
|
| 144 |
+
fused_tokens = self.output_norm(fused_tokens)
|
| 145 |
+
|
| 146 |
+
if not return_diagnostics:
|
| 147 |
+
return fused_tokens
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"fused_tokens": fused_tokens,
|
| 151 |
+
"prior_weights": prior_weights,
|
| 152 |
+
"topo_embedding": topo_embedding,
|
| 153 |
+
"prior_gate": self.prior_gate.detach().clone(),
|
| 154 |
+
"topo_gate": self.topo_gate.detach().clone(),
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
def _validate_visual_tokens(self, visual_tokens: torch.Tensor) -> None:
|
| 158 |
+
if not torch.is_tensor(visual_tokens):
|
| 159 |
+
raise TypeError("visual_tokens phải là torch.Tensor")
|
| 160 |
+
if visual_tokens.ndim != 3:
|
| 161 |
+
raise ValueError(
|
| 162 |
+
f"visual_tokens phải có shape [B, N, D], nhận {tuple(visual_tokens.shape)}"
|
| 163 |
+
)
|
| 164 |
+
if visual_tokens.shape[-1] != self.config.d_model:
|
| 165 |
+
raise ValueError(
|
| 166 |
+
f"visual_tokens D={visual_tokens.shape[-1]} không khớp d_model={self.config.d_model}"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
def _prepare_prior_mask(
|
| 170 |
+
self,
|
| 171 |
+
prior_mask: torch.Tensor,
|
| 172 |
+
batch_size: int,
|
| 173 |
+
num_tokens: int,
|
| 174 |
+
device: torch.device,
|
| 175 |
+
dtype: torch.dtype,
|
| 176 |
+
) -> torch.Tensor:
|
| 177 |
+
if not torch.is_tensor(prior_mask):
|
| 178 |
+
prior_mask = torch.as_tensor(prior_mask)
|
| 179 |
+
|
| 180 |
+
prior_mask = prior_mask.to(device=device, dtype=dtype)
|
| 181 |
+
|
| 182 |
+
if prior_mask.ndim == 3:
|
| 183 |
+
prior_mask = prior_mask.flatten(start_dim=1)
|
| 184 |
+
elif prior_mask.ndim == 2:
|
| 185 |
+
pass
|
| 186 |
+
else:
|
| 187 |
+
raise ValueError(
|
| 188 |
+
f"prior_mask phải có shape [B, N] hoặc [B, H, W], nhận {tuple(prior_mask.shape)}"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
if prior_mask.shape[0] != batch_size:
|
| 192 |
+
raise ValueError(
|
| 193 |
+
f"prior_mask batch={prior_mask.shape[0]} không khớp visual batch={batch_size}"
|
| 194 |
+
)
|
| 195 |
+
if prior_mask.shape[1] != num_tokens:
|
| 196 |
+
raise ValueError(
|
| 197 |
+
f"prior_mask N={prior_mask.shape[1]} không khớp visual tokens N={num_tokens}"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if self.config.normalize_prior:
|
| 201 |
+
prior_mask = self._minmax_normalize_per_sample(prior_mask)
|
| 202 |
+
|
| 203 |
+
return prior_mask.unsqueeze(-1)
|
| 204 |
+
|
| 205 |
+
def _prepare_topo_features(
|
| 206 |
+
self,
|
| 207 |
+
topo_features: torch.Tensor,
|
| 208 |
+
batch_size: int,
|
| 209 |
+
num_tokens: int,
|
| 210 |
+
device: torch.device,
|
| 211 |
+
dtype: torch.dtype,
|
| 212 |
+
) -> torch.Tensor:
|
| 213 |
+
if not torch.is_tensor(topo_features):
|
| 214 |
+
topo_features = torch.as_tensor(topo_features)
|
| 215 |
+
|
| 216 |
+
topo_features = topo_features.to(device=device, dtype=dtype)
|
| 217 |
+
|
| 218 |
+
if topo_features.ndim == 4:
|
| 219 |
+
topo_features = topo_features.flatten(start_dim=1, end_dim=2)
|
| 220 |
+
elif topo_features.ndim == 3:
|
| 221 |
+
pass
|
| 222 |
+
else:
|
| 223 |
+
raise ValueError(
|
| 224 |
+
"topo_features phải có shape [B, N, F] hoặc [B, H, W, F], "
|
| 225 |
+
f"nhận {tuple(topo_features.shape)}"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
if topo_features.shape[0] != batch_size:
|
| 229 |
+
raise ValueError(
|
| 230 |
+
f"topo_features batch={topo_features.shape[0]} không khớp visual batch={batch_size}"
|
| 231 |
+
)
|
| 232 |
+
if topo_features.shape[1] != num_tokens:
|
| 233 |
+
raise ValueError(
|
| 234 |
+
f"topo_features N={topo_features.shape[1]} không khớp visual tokens N={num_tokens}"
|
| 235 |
+
)
|
| 236 |
+
if topo_features.shape[2] != self.config.topo_feature_dim:
|
| 237 |
+
raise ValueError(
|
| 238 |
+
f"topo_features F={topo_features.shape[2]} không khớp "
|
| 239 |
+
f"topo_feature_dim={self.config.topo_feature_dim}"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
return topo_features
|
| 243 |
+
|
| 244 |
+
@staticmethod
|
| 245 |
+
def _minmax_normalize_per_sample(values: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
|
| 246 |
+
min_values = values.amin(dim=1, keepdim=True)
|
| 247 |
+
max_values = values.amax(dim=1, keepdim=True)
|
| 248 |
+
return (values - min_values) / (max_values - min_values + eps)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def build_structural_visual_fusion(
|
| 252 |
+
d_model: int,
|
| 253 |
+
topo_feature_dim: int,
|
| 254 |
+
**kwargs,
|
| 255 |
+
) -> StructuralVisualFusion:
|
| 256 |
+
"""Factory tiện dụng cho training scripts."""
|
| 257 |
+
config = StructuralFusionConfig(
|
| 258 |
+
d_model=d_model,
|
| 259 |
+
topo_feature_dim=topo_feature_dim,
|
| 260 |
+
**kwargs,
|
| 261 |
+
)
|
| 262 |
+
return StructuralVisualFusion(config)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
__all__ = [
|
| 266 |
+
"StructuralFusionConfig",
|
| 267 |
+
"StructuralVisualFusion",
|
| 268 |
+
"build_structural_visual_fusion",
|
| 269 |
+
]
|
src/models/llm_decoder.py
ADDED
|
File without changes
|
src/models/structural_vqa.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Dict, Optional, Sequence, Tuple
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
from src.alignment.sinkhorn_ot import SinkhornOTConfig, SinkhornOTRouter
|
| 9 |
+
from src.models.text_encoder import BasicVQATokenizer, SimpleTextEncoder, SimpleTextEncoderConfig
|
| 10 |
+
from src.models.vision_encoder import StructuralVisionEncoder, build_structural_vision_encoder
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AnswerVocabulary:
|
| 14 |
+
"""
|
| 15 |
+
Map answer text <-> class id cho prototype VQA classification.
|
| 16 |
+
|
| 17 |
+
Đây là baseline classification, không phải generative decoder.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, answers: Optional[Sequence[str]] = None, min_freq: int = 1, max_answers: Optional[int] = None):
|
| 21 |
+
self.answer_to_id: Dict[str, int] = {}
|
| 22 |
+
self.id_to_answer: Dict[int, str] = {}
|
| 23 |
+
if answers is not None:
|
| 24 |
+
self.build(answers, min_freq=min_freq, max_answers=max_answers)
|
| 25 |
+
|
| 26 |
+
def __len__(self) -> int:
|
| 27 |
+
return len(self.answer_to_id)
|
| 28 |
+
|
| 29 |
+
def build(self, answers: Sequence[str], min_freq: int = 1, max_answers: Optional[int] = None) -> None:
|
| 30 |
+
from collections import Counter
|
| 31 |
+
|
| 32 |
+
counter = Counter(self.normalize(answer) for answer in answers)
|
| 33 |
+
self.answer_to_id.clear()
|
| 34 |
+
self.id_to_answer.clear()
|
| 35 |
+
|
| 36 |
+
for answer, freq in counter.most_common(max_answers):
|
| 37 |
+
if freq < min_freq:
|
| 38 |
+
continue
|
| 39 |
+
idx = len(self.answer_to_id)
|
| 40 |
+
self.answer_to_id[answer] = idx
|
| 41 |
+
self.id_to_answer[idx] = answer
|
| 42 |
+
|
| 43 |
+
@staticmethod
|
| 44 |
+
def normalize(answer: str) -> str:
|
| 45 |
+
return " ".join(str(answer or "").strip().lower().split())
|
| 46 |
+
|
| 47 |
+
def encode(self, answer: str) -> int:
|
| 48 |
+
key = self.normalize(answer)
|
| 49 |
+
if key not in self.answer_to_id:
|
| 50 |
+
raise KeyError(f"Answer ngoài vocabulary: {answer}")
|
| 51 |
+
return self.answer_to_id[key]
|
| 52 |
+
|
| 53 |
+
def batch_encode(self, answers: Sequence[str]) -> torch.Tensor:
|
| 54 |
+
return torch.tensor([self.encode(answer) for answer in answers], dtype=torch.long)
|
| 55 |
+
|
| 56 |
+
def decode(self, answer_id: int) -> str:
|
| 57 |
+
return self.id_to_answer[int(answer_id)]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class StructuralVQAConfig:
|
| 62 |
+
d_model: int = 768
|
| 63 |
+
num_answers: int = 1000
|
| 64 |
+
text_max_length: int = 64
|
| 65 |
+
dropout: float = 0.10
|
| 66 |
+
ot_loss_weight: float = 0.01
|
| 67 |
+
use_ot: bool = True
|
| 68 |
+
use_topological_loss: bool = True
|
| 69 |
+
use_prior_align_loss: bool = True
|
| 70 |
+
use_global_topo_loss: bool = True
|
| 71 |
+
use_patch_topo_loss: bool = False
|
| 72 |
+
prior_loss_weight: float = 0.05
|
| 73 |
+
global_topo_loss_weight: float = 0.01
|
| 74 |
+
patch_topo_loss_weight: float = 0.005
|
| 75 |
+
topo_feature_dim: int = 12
|
| 76 |
+
global_feature_dim: int = 8
|
| 77 |
+
use_prior_as_ot_target: bool = True
|
| 78 |
+
prior_ot_global_mass: float = 0.05
|
| 79 |
+
classifier_hidden_dim: Optional[int] = None
|
| 80 |
+
|
| 81 |
+
def __post_init__(self):
|
| 82 |
+
if self.d_model <= 0:
|
| 83 |
+
raise ValueError("d_model phải > 0")
|
| 84 |
+
if self.num_answers <= 0:
|
| 85 |
+
raise ValueError("num_answers phải > 0")
|
| 86 |
+
if self.topo_feature_dim <= 0:
|
| 87 |
+
raise ValueError("topo_feature_dim phải > 0")
|
| 88 |
+
if self.global_feature_dim <= 0:
|
| 89 |
+
raise ValueError("global_feature_dim phải > 0")
|
| 90 |
+
if not 0.0 <= self.prior_ot_global_mass < 1.0:
|
| 91 |
+
raise ValueError("prior_ot_global_mass phải nằm trong [0, 1)")
|
| 92 |
+
if self.classifier_hidden_dim is None:
|
| 93 |
+
self.classifier_hidden_dim = self.d_model * 2
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class StructuralVQAPrototype(nn.Module):
|
| 97 |
+
"""
|
| 98 |
+
Prototype VQA classification model:
|
| 99 |
+
|
| 100 |
+
image + structural features -> StructuralVisionEncoder -> visual_context
|
| 101 |
+
question_text/input_ids -> SimpleTextEncoder -> text_tokens
|
| 102 |
+
text_tokens + visual_context -> SinkhornOTRouter -> aligned_visual
|
| 103 |
+
pooled multimodal representation -> answer logits
|
| 104 |
+
|
| 105 |
+
Topological losses optional:
|
| 106 |
+
- prior alignment: OT transport should align with lesion prior.
|
| 107 |
+
- global topology reconstruction: global token should reconstruct global features.
|
| 108 |
+
- patch topology reconstruction: fused tokens should reconstruct patch topo features.
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
def __init__(
|
| 112 |
+
self,
|
| 113 |
+
config: StructuralVQAConfig,
|
| 114 |
+
vision_encoder: StructuralVisionEncoder,
|
| 115 |
+
text_encoder: SimpleTextEncoder,
|
| 116 |
+
tokenizer: Optional[BasicVQATokenizer] = None,
|
| 117 |
+
ot_router: Optional[SinkhornOTRouter] = None,
|
| 118 |
+
):
|
| 119 |
+
super().__init__()
|
| 120 |
+
self.config = config
|
| 121 |
+
self.vision_encoder = vision_encoder
|
| 122 |
+
self.text_encoder = text_encoder
|
| 123 |
+
self.tokenizer = tokenizer
|
| 124 |
+
self.ot_router = ot_router or SinkhornOTRouter(SinkhornOTConfig())
|
| 125 |
+
|
| 126 |
+
fusion_dim = config.d_model * 4
|
| 127 |
+
self.classifier = nn.Sequential(
|
| 128 |
+
nn.LayerNorm(fusion_dim),
|
| 129 |
+
nn.Linear(fusion_dim, config.classifier_hidden_dim),
|
| 130 |
+
nn.GELU(),
|
| 131 |
+
nn.Dropout(config.dropout),
|
| 132 |
+
nn.Linear(config.classifier_hidden_dim, config.num_answers),
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
self.global_topo_head = nn.Sequential(
|
| 136 |
+
nn.LayerNorm(config.d_model),
|
| 137 |
+
nn.Linear(config.d_model, config.classifier_hidden_dim),
|
| 138 |
+
nn.GELU(),
|
| 139 |
+
nn.Dropout(config.dropout),
|
| 140 |
+
nn.Linear(config.classifier_hidden_dim, config.global_feature_dim),
|
| 141 |
+
)
|
| 142 |
+
self.patch_topo_head = nn.Sequential(
|
| 143 |
+
nn.LayerNorm(config.d_model),
|
| 144 |
+
nn.Linear(config.d_model, config.classifier_hidden_dim),
|
| 145 |
+
nn.GELU(),
|
| 146 |
+
nn.Dropout(config.dropout),
|
| 147 |
+
nn.Linear(config.classifier_hidden_dim, config.topo_feature_dim),
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
def forward(
|
| 151 |
+
self,
|
| 152 |
+
image: torch.Tensor,
|
| 153 |
+
prior_mask: torch.Tensor,
|
| 154 |
+
topo_features: torch.Tensor,
|
| 155 |
+
global_features: torch.Tensor,
|
| 156 |
+
question_text: Optional[Sequence[str]] = None,
|
| 157 |
+
input_ids: Optional[torch.Tensor] = None,
|
| 158 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 159 |
+
answer_ids: Optional[torch.Tensor] = None,
|
| 160 |
+
return_diagnostics: bool = True,
|
| 161 |
+
):
|
| 162 |
+
device = image.device
|
| 163 |
+
|
| 164 |
+
if input_ids is None:
|
| 165 |
+
if question_text is None:
|
| 166 |
+
raise ValueError("Cần truyền question_text hoặc input_ids")
|
| 167 |
+
if self.tokenizer is None:
|
| 168 |
+
raise ValueError("question_text được truyền nhưng model.tokenizer=None")
|
| 169 |
+
tokenized = self.tokenizer.batch_encode(
|
| 170 |
+
question_text,
|
| 171 |
+
max_length=self.config.text_max_length,
|
| 172 |
+
)
|
| 173 |
+
input_ids = tokenized["input_ids"]
|
| 174 |
+
attention_mask = tokenized["attention_mask"]
|
| 175 |
+
|
| 176 |
+
input_ids = input_ids.to(device=device)
|
| 177 |
+
if attention_mask is not None:
|
| 178 |
+
attention_mask = attention_mask.to(device=device)
|
| 179 |
+
prior_mask = prior_mask.to(device=device, dtype=image.dtype)
|
| 180 |
+
topo_features = topo_features.to(device=device, dtype=image.dtype)
|
| 181 |
+
global_features = global_features.to(device=device, dtype=image.dtype)
|
| 182 |
+
|
| 183 |
+
vision_out = self.vision_encoder(
|
| 184 |
+
image=image,
|
| 185 |
+
prior_mask=prior_mask,
|
| 186 |
+
topo_features=topo_features,
|
| 187 |
+
global_features=global_features,
|
| 188 |
+
return_diagnostics=True,
|
| 189 |
+
)
|
| 190 |
+
visual_context = vision_out["visual_context"]
|
| 191 |
+
|
| 192 |
+
text_out = self.text_encoder(
|
| 193 |
+
input_ids=input_ids,
|
| 194 |
+
attention_mask=attention_mask,
|
| 195 |
+
return_dict=True,
|
| 196 |
+
)
|
| 197 |
+
text_tokens = text_out["text_tokens"]
|
| 198 |
+
pooled_text = text_out["pooled_text"]
|
| 199 |
+
attention_mask = text_out["attention_mask"]
|
| 200 |
+
|
| 201 |
+
if self.config.use_ot:
|
| 202 |
+
visual_target_mass = None
|
| 203 |
+
if self.config.use_prior_as_ot_target:
|
| 204 |
+
visual_target_mass = self.build_prior_visual_target_mass(
|
| 205 |
+
prior_mask=prior_mask,
|
| 206 |
+
num_visual_tokens=visual_context.shape[1],
|
| 207 |
+
global_mass=self.config.prior_ot_global_mass,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
ot_out = self.ot_router(
|
| 211 |
+
visual_tokens=visual_context,
|
| 212 |
+
text_tokens=text_tokens,
|
| 213 |
+
text_mask=attention_mask,
|
| 214 |
+
visual_target_mass=visual_target_mass,
|
| 215 |
+
return_diagnostics=True,
|
| 216 |
+
)
|
| 217 |
+
aligned_visual_tokens = ot_out["aligned_visual"]
|
| 218 |
+
pooled_aligned_visual = SimpleTextEncoder.masked_mean_pool(
|
| 219 |
+
aligned_visual_tokens,
|
| 220 |
+
attention_mask,
|
| 221 |
+
)
|
| 222 |
+
ot_cost = ot_out["ot_cost"]
|
| 223 |
+
else:
|
| 224 |
+
ot_out = None
|
| 225 |
+
pooled_aligned_visual = visual_context.mean(dim=1)
|
| 226 |
+
ot_cost = visual_context.new_tensor(0.0)
|
| 227 |
+
|
| 228 |
+
pooled_visual = visual_context.mean(dim=1)
|
| 229 |
+
multimodal = torch.cat(
|
| 230 |
+
[
|
| 231 |
+
pooled_text,
|
| 232 |
+
pooled_visual,
|
| 233 |
+
pooled_aligned_visual,
|
| 234 |
+
pooled_text * pooled_aligned_visual,
|
| 235 |
+
],
|
| 236 |
+
dim=-1,
|
| 237 |
+
)
|
| 238 |
+
logits = self.classifier(multimodal)
|
| 239 |
+
|
| 240 |
+
topo_loss_out = self.compute_topological_losses(
|
| 241 |
+
vision_out=vision_out,
|
| 242 |
+
ot_out=ot_out,
|
| 243 |
+
prior_mask=prior_mask,
|
| 244 |
+
topo_features=topo_features,
|
| 245 |
+
global_features=global_features,
|
| 246 |
+
attention_mask=attention_mask,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
loss = None
|
| 250 |
+
ce_loss = None
|
| 251 |
+
if answer_ids is not None:
|
| 252 |
+
answer_ids = answer_ids.to(device=device)
|
| 253 |
+
ce_loss = F.cross_entropy(logits, answer_ids)
|
| 254 |
+
loss = ce_loss + self.config.ot_loss_weight * ot_cost + topo_loss_out["topological_loss"]
|
| 255 |
+
|
| 256 |
+
if not return_diagnostics:
|
| 257 |
+
return logits
|
| 258 |
+
|
| 259 |
+
output = {
|
| 260 |
+
"logits": logits,
|
| 261 |
+
"loss": loss,
|
| 262 |
+
"ce_loss": ce_loss,
|
| 263 |
+
"ot_cost": ot_cost,
|
| 264 |
+
"visual_context": visual_context,
|
| 265 |
+
"text_tokens": text_tokens,
|
| 266 |
+
"pooled_text": pooled_text,
|
| 267 |
+
"pooled_visual": pooled_visual,
|
| 268 |
+
"pooled_aligned_visual": pooled_aligned_visual,
|
| 269 |
+
"vision_out": vision_out,
|
| 270 |
+
**topo_loss_out,
|
| 271 |
+
}
|
| 272 |
+
if ot_out is not None:
|
| 273 |
+
output["ot_out"] = ot_out
|
| 274 |
+
return output
|
| 275 |
+
|
| 276 |
+
def compute_topological_losses(
|
| 277 |
+
self,
|
| 278 |
+
vision_out: Dict[str, torch.Tensor],
|
| 279 |
+
ot_out: Optional[Dict[str, torch.Tensor]],
|
| 280 |
+
prior_mask: torch.Tensor,
|
| 281 |
+
topo_features: torch.Tensor,
|
| 282 |
+
global_features: torch.Tensor,
|
| 283 |
+
attention_mask: torch.Tensor,
|
| 284 |
+
) -> Dict[str, torch.Tensor]:
|
| 285 |
+
zero = prior_mask.new_tensor(0.0)
|
| 286 |
+
prior_align_loss = zero
|
| 287 |
+
global_topo_loss = zero
|
| 288 |
+
patch_topo_loss = zero
|
| 289 |
+
global_topo_pred = None
|
| 290 |
+
patch_topo_pred = None
|
| 291 |
+
|
| 292 |
+
if self.config.use_topological_loss:
|
| 293 |
+
if self.config.use_prior_align_loss and ot_out is not None:
|
| 294 |
+
prior_align_loss = self.compute_prior_alignment_loss(
|
| 295 |
+
transport_plan=ot_out["transport_plan"],
|
| 296 |
+
prior_mask=prior_mask,
|
| 297 |
+
attention_mask=attention_mask,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
if self.config.use_global_topo_loss:
|
| 301 |
+
global_token = vision_out.get("global_token")
|
| 302 |
+
if global_token is not None:
|
| 303 |
+
global_topo_pred = self.global_topo_head(global_token.squeeze(1))
|
| 304 |
+
global_topo_loss = F.mse_loss(global_topo_pred, global_features.float())
|
| 305 |
+
|
| 306 |
+
if self.config.use_patch_topo_loss:
|
| 307 |
+
fused_tokens = vision_out["fused_tokens"]
|
| 308 |
+
patch_topo_pred = self.patch_topo_head(fused_tokens)
|
| 309 |
+
topo_target = topo_features.flatten(start_dim=1, end_dim=2).float()
|
| 310 |
+
patch_topo_loss = F.mse_loss(patch_topo_pred, topo_target)
|
| 311 |
+
|
| 312 |
+
effective_prior_loss_weight = 0.0 if self.config.use_prior_as_ot_target else self.config.prior_loss_weight
|
| 313 |
+
topological_loss = (
|
| 314 |
+
effective_prior_loss_weight * prior_align_loss
|
| 315 |
+
+ self.config.global_topo_loss_weight * global_topo_loss
|
| 316 |
+
+ self.config.patch_topo_loss_weight * patch_topo_loss
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
return {
|
| 320 |
+
"prior_align_loss": prior_align_loss,
|
| 321 |
+
"global_topo_loss": global_topo_loss,
|
| 322 |
+
"patch_topo_loss": patch_topo_loss,
|
| 323 |
+
"topological_loss": topological_loss,
|
| 324 |
+
"effective_prior_loss_weight": prior_mask.new_tensor(effective_prior_loss_weight),
|
| 325 |
+
"global_topo_pred": global_topo_pred,
|
| 326 |
+
"patch_topo_pred": patch_topo_pred,
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
@staticmethod
|
| 330 |
+
def compute_prior_alignment_loss(
|
| 331 |
+
transport_plan: torch.Tensor,
|
| 332 |
+
prior_mask: torch.Tensor,
|
| 333 |
+
attention_mask: torch.Tensor,
|
| 334 |
+
eps: float = 1e-8,
|
| 335 |
+
) -> torch.Tensor:
|
| 336 |
+
if transport_plan.shape[-1] < 2:
|
| 337 |
+
return transport_plan.new_tensor(0.0)
|
| 338 |
+
|
| 339 |
+
patch_transport = transport_plan[:, :, 1:]
|
| 340 |
+
text_mask = attention_mask.to(device=transport_plan.device, dtype=transport_plan.dtype).unsqueeze(-1)
|
| 341 |
+
patch_attention = (patch_transport * text_mask).sum(dim=1)
|
| 342 |
+
|
| 343 |
+
prior = prior_mask.flatten(start_dim=1).to(device=transport_plan.device, dtype=transport_plan.dtype)
|
| 344 |
+
if prior.shape[1] != patch_attention.shape[1]:
|
| 345 |
+
raise ValueError(
|
| 346 |
+
f"prior patches={prior.shape[1]} không khớp transport patches={patch_attention.shape[1]}"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
patch_attention = patch_attention / patch_attention.sum(dim=1, keepdim=True).clamp_min(eps)
|
| 350 |
+
prior = prior / prior.sum(dim=1, keepdim=True).clamp_min(eps)
|
| 351 |
+
cosine = F.cosine_similarity(patch_attention, prior, dim=1, eps=eps)
|
| 352 |
+
return (1.0 - cosine).mean()
|
| 353 |
+
|
| 354 |
+
@staticmethod
|
| 355 |
+
def build_prior_visual_target_mass(
|
| 356 |
+
prior_mask: torch.Tensor,
|
| 357 |
+
num_visual_tokens: int,
|
| 358 |
+
global_mass: float = 0.05,
|
| 359 |
+
eps: float = 1e-8,
|
| 360 |
+
) -> torch.Tensor:
|
| 361 |
+
"""
|
| 362 |
+
Tạo target marginal cho visual side của Sinkhorn từ lesion prior.
|
| 363 |
+
|
| 364 |
+
visual_context có dạng:
|
| 365 |
+
token 0 = global structural token
|
| 366 |
+
token 1..196 = patch tokens
|
| 367 |
+
|
| 368 |
+
Nếu Sinkhorn dùng target marginal uniform, tổng mass trên patch tokens
|
| 369 |
+
bị ép gần đều nên prior_align_loss gần như không thể giảm. Hàm này
|
| 370 |
+
đổi target marginal thành:
|
| 371 |
+
global token mass = global_mass
|
| 372 |
+
patch token mass = (1 - global_mass) * normalized prior_mask
|
| 373 |
+
"""
|
| 374 |
+
if num_visual_tokens < 2:
|
| 375 |
+
raise ValueError("Cần ít nhất 1 global token + patch tokens để dùng prior target mass")
|
| 376 |
+
|
| 377 |
+
prior = prior_mask.flatten(start_dim=1)
|
| 378 |
+
expected_patches = num_visual_tokens - 1
|
| 379 |
+
if prior.shape[1] != expected_patches:
|
| 380 |
+
raise ValueError(
|
| 381 |
+
f"prior patches={prior.shape[1]} không khớp visual patch tokens={expected_patches}"
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
prior = prior.clamp_min(0.0)
|
| 385 |
+
fallback = torch.ones_like(prior) / max(expected_patches, 1)
|
| 386 |
+
prior_sum = prior.sum(dim=1, keepdim=True)
|
| 387 |
+
prior_dist = torch.where(prior_sum > eps, prior / prior_sum.clamp_min(eps), fallback)
|
| 388 |
+
|
| 389 |
+
global_column = prior.new_full((prior.shape[0], 1), global_mass)
|
| 390 |
+
patch_mass = (1.0 - global_mass) * prior_dist
|
| 391 |
+
return torch.cat([global_column, patch_mass], dim=1)
|
| 392 |
+
|
| 393 |
+
@torch.no_grad()
|
| 394 |
+
def predict(self, *args, **kwargs) -> torch.Tensor:
|
| 395 |
+
self.eval()
|
| 396 |
+
out = self.forward(*args, **kwargs)
|
| 397 |
+
logits = out["logits"] if isinstance(out, dict) else out
|
| 398 |
+
return logits.argmax(dim=-1)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def build_structural_vqa_prototype(
|
| 402 |
+
questions: Sequence[str],
|
| 403 |
+
answers: Sequence[str],
|
| 404 |
+
d_model: int = 768,
|
| 405 |
+
num_answers: Optional[int] = None,
|
| 406 |
+
text_max_length: int = 64,
|
| 407 |
+
vision_pretrained: bool = False,
|
| 408 |
+
vision_backend: str = "timm",
|
| 409 |
+
freeze_vision_backbone: bool = True,
|
| 410 |
+
max_text_vocab_size: int = 30000,
|
| 411 |
+
max_answer_vocab_size: Optional[int] = None,
|
| 412 |
+
ot_loss_weight: float = 0.01,
|
| 413 |
+
use_topological_loss: bool = True,
|
| 414 |
+
use_prior_align_loss: bool = True,
|
| 415 |
+
use_global_topo_loss: bool = True,
|
| 416 |
+
use_patch_topo_loss: bool = False,
|
| 417 |
+
prior_loss_weight: float = 0.05,
|
| 418 |
+
global_topo_loss_weight: float = 0.01,
|
| 419 |
+
patch_topo_loss_weight: float = 0.005,
|
| 420 |
+
use_prior_as_ot_target: bool = True,
|
| 421 |
+
prior_ot_global_mass: float = 0.05,
|
| 422 |
+
) -> Tuple[StructuralVQAPrototype, BasicVQATokenizer, AnswerVocabulary]:
|
| 423 |
+
tokenizer = BasicVQATokenizer.build_from_texts(
|
| 424 |
+
questions,
|
| 425 |
+
max_vocab_size=max_text_vocab_size,
|
| 426 |
+
)
|
| 427 |
+
answer_vocab = AnswerVocabulary(
|
| 428 |
+
answers,
|
| 429 |
+
max_answers=max_answer_vocab_size,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
if num_answers is None:
|
| 433 |
+
num_answers = len(answer_vocab)
|
| 434 |
+
if num_answers <= 0:
|
| 435 |
+
raise ValueError("Không build được answer vocabulary; num_answers=0")
|
| 436 |
+
|
| 437 |
+
vision_encoder = build_structural_vision_encoder(
|
| 438 |
+
d_model=d_model,
|
| 439 |
+
pretrained=vision_pretrained,
|
| 440 |
+
backend=vision_backend,
|
| 441 |
+
freeze_backbone=freeze_vision_backbone,
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
text_config = SimpleTextEncoderConfig(
|
| 445 |
+
vocab_size=len(tokenizer),
|
| 446 |
+
d_model=d_model,
|
| 447 |
+
max_length=text_max_length,
|
| 448 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 449 |
+
)
|
| 450 |
+
text_encoder = SimpleTextEncoder(text_config)
|
| 451 |
+
|
| 452 |
+
config = StructuralVQAConfig(
|
| 453 |
+
d_model=d_model,
|
| 454 |
+
num_answers=num_answers,
|
| 455 |
+
text_max_length=text_max_length,
|
| 456 |
+
ot_loss_weight=ot_loss_weight,
|
| 457 |
+
use_topological_loss=use_topological_loss,
|
| 458 |
+
use_prior_align_loss=use_prior_align_loss,
|
| 459 |
+
use_global_topo_loss=use_global_topo_loss,
|
| 460 |
+
use_patch_topo_loss=use_patch_topo_loss,
|
| 461 |
+
prior_loss_weight=prior_loss_weight,
|
| 462 |
+
global_topo_loss_weight=global_topo_loss_weight,
|
| 463 |
+
patch_topo_loss_weight=patch_topo_loss_weight,
|
| 464 |
+
use_prior_as_ot_target=use_prior_as_ot_target,
|
| 465 |
+
prior_ot_global_mass=prior_ot_global_mass,
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
model = StructuralVQAPrototype(
|
| 469 |
+
config=config,
|
| 470 |
+
vision_encoder=vision_encoder,
|
| 471 |
+
text_encoder=text_encoder,
|
| 472 |
+
tokenizer=tokenizer,
|
| 473 |
+
)
|
| 474 |
+
return model, tokenizer, answer_vocab
|
src/models/structural_vqa_generative.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Dict, Optional, Sequence
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from peft import LoraConfig, get_peft_model
|
| 11 |
+
except ImportError: # PEFT is optional unless LoRA is enabled.
|
| 12 |
+
LoraConfig = None
|
| 13 |
+
get_peft_model = None
|
| 14 |
+
|
| 15 |
+
from src.alignment.sinkhorn_ot import SinkhornOTConfig, SinkhornOTRouter
|
| 16 |
+
from src.models.structural_vqa import StructuralVQAPrototype
|
| 17 |
+
from src.models.vision_encoder import StructuralVisionEncoder, build_structural_vision_encoder
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class StructuralGenerativeVQAConfig:
|
| 22 |
+
llm_name_or_path: str = "distilgpt2"
|
| 23 |
+
vision_d_model: int = 768
|
| 24 |
+
freeze_llm: bool = True
|
| 25 |
+
ot_loss_weight: float = 0.05
|
| 26 |
+
use_ot: bool = True
|
| 27 |
+
use_ot_fusion: bool = True
|
| 28 |
+
ot_fusion_mode: str = "prefix"
|
| 29 |
+
ot_fusion_dropout: float = 0.10
|
| 30 |
+
use_lora: bool = False
|
| 31 |
+
lora_r: int = 8
|
| 32 |
+
lora_alpha: int = 16
|
| 33 |
+
lora_dropout: float = 0.05
|
| 34 |
+
lora_target_modules: str = "q_proj,v_proj"
|
| 35 |
+
use_prior_as_ot_target: bool = True
|
| 36 |
+
prior_ot_global_mass: float = 0.05
|
| 37 |
+
use_topological_loss: bool = True
|
| 38 |
+
use_prior_align_loss: bool = True
|
| 39 |
+
use_global_topo_loss: bool = True
|
| 40 |
+
use_patch_topo_loss: bool = False
|
| 41 |
+
prior_loss_weight: float = 0.05
|
| 42 |
+
global_topo_loss_weight: float = 0.01
|
| 43 |
+
patch_topo_loss_weight: float = 0.005
|
| 44 |
+
topo_feature_dim: int = 12
|
| 45 |
+
global_feature_dim: int = 8
|
| 46 |
+
max_question_length: int = 128
|
| 47 |
+
max_answer_length: int = 128
|
| 48 |
+
dropout: float = 0.10
|
| 49 |
+
|
| 50 |
+
def __post_init__(self):
|
| 51 |
+
if not 0.0 <= self.prior_ot_global_mass < 1.0:
|
| 52 |
+
raise ValueError("prior_ot_global_mass phải nằm trong [0, 1)")
|
| 53 |
+
if self.ot_fusion_mode not in {"none", "prefix"}:
|
| 54 |
+
raise ValueError("ot_fusion_mode hiện chỉ hỗ trợ 'none' hoặc 'prefix'")
|
| 55 |
+
if self.use_lora and (LoraConfig is None or get_peft_model is None):
|
| 56 |
+
raise ImportError("use_lora=True nhưng chưa cài package 'peft'. Hãy chạy: pip install peft")
|
| 57 |
+
if self.lora_r <= 0:
|
| 58 |
+
raise ValueError("lora_r phải > 0")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class VisualToLLMProjector(nn.Module):
|
| 62 |
+
def __init__(self, vision_dim: int, llm_dim: int, dropout: float = 0.10):
|
| 63 |
+
super().__init__()
|
| 64 |
+
self.net = nn.Sequential(
|
| 65 |
+
nn.LayerNorm(vision_dim),
|
| 66 |
+
nn.Linear(vision_dim, llm_dim),
|
| 67 |
+
nn.GELU(),
|
| 68 |
+
nn.Dropout(dropout),
|
| 69 |
+
nn.Linear(llm_dim, llm_dim),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def forward(self, visual_context: torch.Tensor) -> torch.Tensor:
|
| 73 |
+
return self.net(visual_context)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class StructuralGenerativeVQA(nn.Module):
|
| 77 |
+
"""
|
| 78 |
+
Generative VQA model:
|
| 79 |
+
|
| 80 |
+
StructuralVisionEncoder -> visual prefix embeddings -> causal LM.
|
| 81 |
+
|
| 82 |
+
Nếu `use_ot_fusion=True`, Sinkhorn OT aligned visual features cũng được
|
| 83 |
+
project thành prefix tokens và đưa trực tiếp vào LLM input.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(
|
| 87 |
+
self,
|
| 88 |
+
config: StructuralGenerativeVQAConfig,
|
| 89 |
+
vision_encoder: StructuralVisionEncoder,
|
| 90 |
+
):
|
| 91 |
+
super().__init__()
|
| 92 |
+
self.config = config
|
| 93 |
+
self.vision_encoder = vision_encoder
|
| 94 |
+
self.tokenizer = AutoTokenizer.from_pretrained(config.llm_name_or_path)
|
| 95 |
+
if self.tokenizer.pad_token is None:
|
| 96 |
+
self.tokenizer.pad_token = self.tokenizer.eos_token
|
| 97 |
+
|
| 98 |
+
self.llm = AutoModelForCausalLM.from_pretrained(config.llm_name_or_path)
|
| 99 |
+
self.llm.config.pad_token_id = self.tokenizer.pad_token_id
|
| 100 |
+
llm_dim = self.llm.get_input_embeddings().embedding_dim
|
| 101 |
+
|
| 102 |
+
if config.freeze_llm:
|
| 103 |
+
for param in self.llm.parameters():
|
| 104 |
+
param.requires_grad = False
|
| 105 |
+
|
| 106 |
+
if config.use_lora:
|
| 107 |
+
target_modules = [m.strip() for m in config.lora_target_modules.split(",") if m.strip()]
|
| 108 |
+
lora_config = LoraConfig(
|
| 109 |
+
r=config.lora_r,
|
| 110 |
+
lora_alpha=config.lora_alpha,
|
| 111 |
+
lora_dropout=config.lora_dropout,
|
| 112 |
+
target_modules=target_modules,
|
| 113 |
+
bias="none",
|
| 114 |
+
task_type="CAUSAL_LM",
|
| 115 |
+
)
|
| 116 |
+
self.llm = get_peft_model(self.llm, lora_config)
|
| 117 |
+
self.llm.print_trainable_parameters()
|
| 118 |
+
|
| 119 |
+
self.visual_projector = VisualToLLMProjector(
|
| 120 |
+
vision_dim=config.vision_d_model,
|
| 121 |
+
llm_dim=llm_dim,
|
| 122 |
+
dropout=config.dropout,
|
| 123 |
+
)
|
| 124 |
+
self.ot_visual_projector = VisualToLLMProjector(
|
| 125 |
+
vision_dim=config.vision_d_model,
|
| 126 |
+
llm_dim=llm_dim,
|
| 127 |
+
dropout=config.ot_fusion_dropout,
|
| 128 |
+
)
|
| 129 |
+
self.ot_text_projector = nn.Linear(llm_dim, config.vision_d_model)
|
| 130 |
+
self.ot_router = SinkhornOTRouter(SinkhornOTConfig())
|
| 131 |
+
|
| 132 |
+
hidden = config.vision_d_model * 2
|
| 133 |
+
self.global_topo_head = nn.Sequential(
|
| 134 |
+
nn.LayerNorm(config.vision_d_model),
|
| 135 |
+
nn.Linear(config.vision_d_model, hidden),
|
| 136 |
+
nn.GELU(),
|
| 137 |
+
nn.Dropout(config.dropout),
|
| 138 |
+
nn.Linear(hidden, config.global_feature_dim),
|
| 139 |
+
)
|
| 140 |
+
self.patch_topo_head = nn.Sequential(
|
| 141 |
+
nn.LayerNorm(config.vision_d_model),
|
| 142 |
+
nn.Linear(config.vision_d_model, hidden),
|
| 143 |
+
nn.GELU(),
|
| 144 |
+
nn.Dropout(config.dropout),
|
| 145 |
+
nn.Linear(hidden, config.topo_feature_dim),
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
def forward(
|
| 149 |
+
self,
|
| 150 |
+
image: torch.Tensor,
|
| 151 |
+
prior_mask: torch.Tensor,
|
| 152 |
+
topo_features: torch.Tensor,
|
| 153 |
+
global_features: torch.Tensor,
|
| 154 |
+
question_text: Sequence[str],
|
| 155 |
+
answer_text: Optional[Sequence[str]] = None,
|
| 156 |
+
return_diagnostics: bool = True,
|
| 157 |
+
) -> Dict[str, torch.Tensor]:
|
| 158 |
+
device = image.device
|
| 159 |
+
prior_mask = prior_mask.to(device=device, dtype=image.dtype)
|
| 160 |
+
topo_features = topo_features.to(device=device, dtype=image.dtype)
|
| 161 |
+
global_features = global_features.to(device=device, dtype=image.dtype)
|
| 162 |
+
|
| 163 |
+
vision_out = self.vision_encoder(
|
| 164 |
+
image=image,
|
| 165 |
+
prior_mask=prior_mask,
|
| 166 |
+
topo_features=topo_features,
|
| 167 |
+
global_features=global_features,
|
| 168 |
+
return_diagnostics=True,
|
| 169 |
+
)
|
| 170 |
+
visual_context = vision_out["visual_context"]
|
| 171 |
+
|
| 172 |
+
prompt_text = [self._format_prompt(q, use_chat_template=True) for q in question_text]
|
| 173 |
+
prompt = self.tokenizer(
|
| 174 |
+
prompt_text,
|
| 175 |
+
padding=True,
|
| 176 |
+
truncation=True,
|
| 177 |
+
max_length=self.config.max_question_length,
|
| 178 |
+
return_tensors="pt",
|
| 179 |
+
).to(device)
|
| 180 |
+
prompt_embeds = self.llm.get_input_embeddings()(prompt.input_ids)
|
| 181 |
+
llm_dtype = self.llm.get_input_embeddings().weight.dtype
|
| 182 |
+
|
| 183 |
+
ot_out = self._compute_ot(
|
| 184 |
+
visual_context=visual_context,
|
| 185 |
+
prompt_embeds=prompt_embeds,
|
| 186 |
+
prompt_attention_mask=prompt.attention_mask,
|
| 187 |
+
prior_mask=prior_mask,
|
| 188 |
+
)
|
| 189 |
+
ot_cost = ot_out["ot_cost"] if ot_out is not None else visual_context.new_tensor(0.0)
|
| 190 |
+
|
| 191 |
+
visual_prefix = self.visual_projector(visual_context).to(dtype=llm_dtype)
|
| 192 |
+
prefix_parts = [visual_prefix]
|
| 193 |
+
prefix_masks = [torch.ones(visual_prefix.shape[:2], device=device, dtype=prompt.attention_mask.dtype)]
|
| 194 |
+
ot_aligned_prefix = None
|
| 195 |
+
|
| 196 |
+
if self._use_ot_prefix(ot_out):
|
| 197 |
+
ot_aligned_prefix = self.ot_visual_projector(ot_out["aligned_visual"]).to(dtype=llm_dtype)
|
| 198 |
+
prefix_parts.append(ot_aligned_prefix)
|
| 199 |
+
prefix_masks.append(prompt.attention_mask)
|
| 200 |
+
|
| 201 |
+
prefix_embeds = torch.cat(prefix_parts, dim=1)
|
| 202 |
+
prefix_attention = torch.cat(prefix_masks, dim=1)
|
| 203 |
+
full_embeds = torch.cat([prefix_embeds, prompt_embeds], dim=1)
|
| 204 |
+
full_attention = torch.cat([prefix_attention, prompt.attention_mask], dim=1)
|
| 205 |
+
|
| 206 |
+
labels = None
|
| 207 |
+
if answer_text is not None:
|
| 208 |
+
answer = self.tokenizer(
|
| 209 |
+
[str(a) + self.tokenizer.eos_token for a in answer_text],
|
| 210 |
+
padding=True,
|
| 211 |
+
truncation=True,
|
| 212 |
+
max_length=self.config.max_answer_length,
|
| 213 |
+
return_tensors="pt",
|
| 214 |
+
).to(device)
|
| 215 |
+
answer_embeds = self.llm.get_input_embeddings()(answer.input_ids)
|
| 216 |
+
full_embeds = torch.cat([full_embeds, answer_embeds], dim=1)
|
| 217 |
+
full_attention = torch.cat([full_attention, answer.attention_mask], dim=1)
|
| 218 |
+
|
| 219 |
+
ignore_prefix = torch.full(
|
| 220 |
+
(image.shape[0], prefix_embeds.shape[1] + prompt.input_ids.shape[1]),
|
| 221 |
+
-100,
|
| 222 |
+
device=device,
|
| 223 |
+
dtype=torch.long,
|
| 224 |
+
)
|
| 225 |
+
answer_labels = answer.input_ids.masked_fill(answer.attention_mask == 0, -100)
|
| 226 |
+
labels = torch.cat([ignore_prefix, answer_labels], dim=1)
|
| 227 |
+
|
| 228 |
+
llm_out = self.llm(
|
| 229 |
+
inputs_embeds=full_embeds,
|
| 230 |
+
attention_mask=full_attention,
|
| 231 |
+
labels=labels,
|
| 232 |
+
return_dict=True,
|
| 233 |
+
)
|
| 234 |
+
lm_loss = llm_out.loss if labels is not None else visual_context.new_tensor(0.0)
|
| 235 |
+
|
| 236 |
+
topo_out = self.compute_topological_losses(
|
| 237 |
+
vision_out=vision_out,
|
| 238 |
+
ot_out=ot_out,
|
| 239 |
+
prior_mask=prior_mask,
|
| 240 |
+
topo_features=topo_features,
|
| 241 |
+
global_features=global_features,
|
| 242 |
+
attention_mask=prompt.attention_mask,
|
| 243 |
+
)
|
| 244 |
+
loss = lm_loss + self.config.ot_loss_weight * ot_cost + topo_out["topological_loss"] if labels is not None else None
|
| 245 |
+
|
| 246 |
+
if not return_diagnostics:
|
| 247 |
+
return {"loss": loss, "logits": llm_out.logits}
|
| 248 |
+
out = {
|
| 249 |
+
"loss": loss,
|
| 250 |
+
"lm_loss": lm_loss,
|
| 251 |
+
"ot_cost": ot_cost,
|
| 252 |
+
"logits": llm_out.logits,
|
| 253 |
+
"vision_out": vision_out,
|
| 254 |
+
"visual_prefix_shape": torch.tensor(visual_prefix.shape, device=device),
|
| 255 |
+
"ot_fusion_enabled": torch.tensor(self._use_ot_prefix(ot_out), device=device),
|
| 256 |
+
**topo_out,
|
| 257 |
+
}
|
| 258 |
+
if ot_aligned_prefix is not None:
|
| 259 |
+
out["ot_aligned_prefix_shape"] = torch.tensor(ot_aligned_prefix.shape, device=device)
|
| 260 |
+
if ot_out is not None:
|
| 261 |
+
out["ot_out"] = ot_out
|
| 262 |
+
return out
|
| 263 |
+
|
| 264 |
+
@torch.no_grad()
|
| 265 |
+
def generate(
|
| 266 |
+
self,
|
| 267 |
+
image: torch.Tensor,
|
| 268 |
+
prior_mask: torch.Tensor,
|
| 269 |
+
topo_features: torch.Tensor,
|
| 270 |
+
global_features: torch.Tensor,
|
| 271 |
+
question_text: Sequence[str],
|
| 272 |
+
max_new_tokens: int = 64,
|
| 273 |
+
) -> Sequence[str]:
|
| 274 |
+
self.eval()
|
| 275 |
+
device = image.device
|
| 276 |
+
prior_mask = prior_mask.to(device=device, dtype=image.dtype)
|
| 277 |
+
topo_features = topo_features.to(device=device, dtype=image.dtype)
|
| 278 |
+
global_features = global_features.to(device=device, dtype=image.dtype)
|
| 279 |
+
|
| 280 |
+
vision_out = self.vision_encoder(
|
| 281 |
+
image=image,
|
| 282 |
+
prior_mask=prior_mask,
|
| 283 |
+
topo_features=topo_features,
|
| 284 |
+
global_features=global_features,
|
| 285 |
+
return_diagnostics=True,
|
| 286 |
+
)
|
| 287 |
+
visual_context = vision_out["visual_context"]
|
| 288 |
+
prompt = self.tokenizer(
|
| 289 |
+
[self._format_prompt(q, use_chat_template=True) for q in question_text],
|
| 290 |
+
padding=True,
|
| 291 |
+
truncation=True,
|
| 292 |
+
max_length=self.config.max_question_length,
|
| 293 |
+
return_tensors="pt",
|
| 294 |
+
).to(device)
|
| 295 |
+
prompt_embeds = self.llm.get_input_embeddings()(prompt.input_ids)
|
| 296 |
+
llm_dtype = self.llm.get_input_embeddings().weight.dtype
|
| 297 |
+
|
| 298 |
+
ot_out = self._compute_ot(
|
| 299 |
+
visual_context=visual_context,
|
| 300 |
+
prompt_embeds=prompt_embeds,
|
| 301 |
+
prompt_attention_mask=prompt.attention_mask,
|
| 302 |
+
prior_mask=prior_mask,
|
| 303 |
+
)
|
| 304 |
+
visual_prefix = self.visual_projector(visual_context).to(dtype=llm_dtype)
|
| 305 |
+
prefix_parts = [visual_prefix]
|
| 306 |
+
prefix_masks = [torch.ones(visual_prefix.shape[:2], device=device, dtype=prompt.attention_mask.dtype)]
|
| 307 |
+
if self._use_ot_prefix(ot_out):
|
| 308 |
+
prefix_parts.append(self.ot_visual_projector(ot_out["aligned_visual"]).to(dtype=llm_dtype))
|
| 309 |
+
prefix_masks.append(prompt.attention_mask)
|
| 310 |
+
|
| 311 |
+
prefix_embeds = torch.cat(prefix_parts, dim=1)
|
| 312 |
+
prefix_attention = torch.cat(prefix_masks, dim=1)
|
| 313 |
+
inputs_embeds = torch.cat([prefix_embeds, prompt_embeds], dim=1)
|
| 314 |
+
attention_mask = torch.cat([prefix_attention, prompt.attention_mask], dim=1)
|
| 315 |
+
|
| 316 |
+
generated = self.llm.generate(
|
| 317 |
+
inputs_embeds=inputs_embeds,
|
| 318 |
+
attention_mask=attention_mask,
|
| 319 |
+
max_new_tokens=max_new_tokens,
|
| 320 |
+
min_new_tokens=2,
|
| 321 |
+
do_sample=False,
|
| 322 |
+
num_beams=1,
|
| 323 |
+
repetition_penalty=1.15,
|
| 324 |
+
no_repeat_ngram_size=3,
|
| 325 |
+
pad_token_id=self.tokenizer.pad_token_id,
|
| 326 |
+
eos_token_id=self._generation_eos_token_ids(),
|
| 327 |
+
)
|
| 328 |
+
decoded = self.tokenizer.batch_decode(generated, skip_special_tokens=True)
|
| 329 |
+
return [text.strip() for text in decoded]
|
| 330 |
+
|
| 331 |
+
def _compute_ot(
|
| 332 |
+
self,
|
| 333 |
+
visual_context: torch.Tensor,
|
| 334 |
+
prompt_embeds: torch.Tensor,
|
| 335 |
+
prompt_attention_mask: torch.Tensor,
|
| 336 |
+
prior_mask: torch.Tensor,
|
| 337 |
+
) -> Optional[Dict[str, torch.Tensor]]:
|
| 338 |
+
if not self.config.use_ot:
|
| 339 |
+
return None
|
| 340 |
+
prompt_token_embeds = prompt_embeds.detach() if self.config.freeze_llm else prompt_embeds
|
| 341 |
+
prompt_token_embeds = prompt_token_embeds.to(dtype=self.ot_text_projector.weight.dtype)
|
| 342 |
+
text_tokens = self.ot_text_projector(prompt_token_embeds)
|
| 343 |
+
visual_target_mass = None
|
| 344 |
+
if self.config.use_prior_as_ot_target:
|
| 345 |
+
visual_target_mass = StructuralVQAPrototype.build_prior_visual_target_mass(
|
| 346 |
+
prior_mask=prior_mask,
|
| 347 |
+
num_visual_tokens=visual_context.shape[1],
|
| 348 |
+
global_mass=self.config.prior_ot_global_mass,
|
| 349 |
+
)
|
| 350 |
+
return self.ot_router(
|
| 351 |
+
visual_tokens=visual_context,
|
| 352 |
+
text_tokens=text_tokens,
|
| 353 |
+
text_mask=prompt_attention_mask,
|
| 354 |
+
visual_target_mass=visual_target_mass,
|
| 355 |
+
return_diagnostics=True,
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
def _use_ot_prefix(self, ot_out: Optional[Dict[str, torch.Tensor]]) -> bool:
|
| 359 |
+
return (
|
| 360 |
+
self.config.use_ot
|
| 361 |
+
and self.config.use_ot_fusion
|
| 362 |
+
and self.config.ot_fusion_mode == "prefix"
|
| 363 |
+
and ot_out is not None
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
def compute_topological_losses(
|
| 367 |
+
self,
|
| 368 |
+
vision_out: Dict[str, torch.Tensor],
|
| 369 |
+
ot_out: Optional[Dict[str, torch.Tensor]],
|
| 370 |
+
prior_mask: torch.Tensor,
|
| 371 |
+
topo_features: torch.Tensor,
|
| 372 |
+
global_features: torch.Tensor,
|
| 373 |
+
attention_mask: torch.Tensor,
|
| 374 |
+
) -> Dict[str, torch.Tensor]:
|
| 375 |
+
zero = prior_mask.new_tensor(0.0)
|
| 376 |
+
prior_align_loss = zero
|
| 377 |
+
global_topo_loss = zero
|
| 378 |
+
patch_topo_loss = zero
|
| 379 |
+
|
| 380 |
+
if self.config.use_topological_loss:
|
| 381 |
+
if self.config.use_prior_align_loss and ot_out is not None:
|
| 382 |
+
prior_align_loss = StructuralVQAPrototype.compute_prior_alignment_loss(
|
| 383 |
+
transport_plan=ot_out["transport_plan"],
|
| 384 |
+
prior_mask=prior_mask,
|
| 385 |
+
attention_mask=attention_mask,
|
| 386 |
+
)
|
| 387 |
+
if self.config.use_global_topo_loss and vision_out.get("global_token") is not None:
|
| 388 |
+
global_pred = self.global_topo_head(vision_out["global_token"].squeeze(1))
|
| 389 |
+
global_topo_loss = F.mse_loss(global_pred, global_features.float())
|
| 390 |
+
if self.config.use_patch_topo_loss:
|
| 391 |
+
patch_pred = self.patch_topo_head(vision_out["fused_tokens"])
|
| 392 |
+
patch_target = topo_features.flatten(start_dim=1, end_dim=2).float()
|
| 393 |
+
patch_topo_loss = F.mse_loss(patch_pred, patch_target)
|
| 394 |
+
|
| 395 |
+
effective_prior_loss_weight = 0.0 if self.config.use_prior_as_ot_target else self.config.prior_loss_weight
|
| 396 |
+
topological_loss = (
|
| 397 |
+
effective_prior_loss_weight * prior_align_loss
|
| 398 |
+
+ self.config.global_topo_loss_weight * global_topo_loss
|
| 399 |
+
+ self.config.patch_topo_loss_weight * patch_topo_loss
|
| 400 |
+
)
|
| 401 |
+
return {
|
| 402 |
+
"prior_align_loss": prior_align_loss,
|
| 403 |
+
"global_topo_loss": global_topo_loss,
|
| 404 |
+
"patch_topo_loss": patch_topo_loss,
|
| 405 |
+
"topological_loss": topological_loss,
|
| 406 |
+
"effective_prior_loss_weight": prior_mask.new_tensor(effective_prior_loss_weight),
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
def _generation_eos_token_ids(self):
|
| 410 |
+
eos_ids = []
|
| 411 |
+
if self.tokenizer.eos_token_id is not None:
|
| 412 |
+
eos_ids.append(self.tokenizer.eos_token_id)
|
| 413 |
+
im_end_id = self.tokenizer.convert_tokens_to_ids("<|im_end|>")
|
| 414 |
+
if isinstance(im_end_id, int) and im_end_id >= 0 and im_end_id not in eos_ids:
|
| 415 |
+
eos_ids.append(im_end_id)
|
| 416 |
+
return eos_ids or self.tokenizer.eos_token_id
|
| 417 |
+
|
| 418 |
+
def _format_prompt(self, question: str, use_chat_template: bool = True) -> str:
|
| 419 |
+
question = str(question or "").replace("<image>", "").strip()
|
| 420 |
+
instruction = (
|
| 421 |
+
"You are a medical visual question answering assistant. "
|
| 422 |
+
"Answer using only the image evidence. "
|
| 423 |
+
"Give one concise sentence and do not repeat the question."
|
| 424 |
+
)
|
| 425 |
+
if use_chat_template and getattr(self.tokenizer, "chat_template", None):
|
| 426 |
+
messages = [
|
| 427 |
+
{"role": "system", "content": instruction},
|
| 428 |
+
{"role": "user", "content": question},
|
| 429 |
+
]
|
| 430 |
+
return self.tokenizer.apply_chat_template(
|
| 431 |
+
messages,
|
| 432 |
+
tokenize=False,
|
| 433 |
+
add_generation_prompt=True,
|
| 434 |
+
)
|
| 435 |
+
return f"System: {instruction}\nUser: {question}\nAssistant:"
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def build_structural_generative_vqa(
|
| 439 |
+
llm_name_or_path: str = "distilgpt2",
|
| 440 |
+
vision_pretrained: bool = False,
|
| 441 |
+
vision_backend: str = "timm",
|
| 442 |
+
freeze_vision_backbone: bool = True,
|
| 443 |
+
freeze_llm: bool = True,
|
| 444 |
+
**kwargs,
|
| 445 |
+
) -> StructuralGenerativeVQA:
|
| 446 |
+
config = StructuralGenerativeVQAConfig(
|
| 447 |
+
llm_name_or_path=llm_name_or_path,
|
| 448 |
+
freeze_llm=freeze_llm,
|
| 449 |
+
**kwargs,
|
| 450 |
+
)
|
| 451 |
+
vision_encoder = build_structural_vision_encoder(
|
| 452 |
+
d_model=config.vision_d_model,
|
| 453 |
+
pretrained=vision_pretrained,
|
| 454 |
+
backend=vision_backend,
|
| 455 |
+
freeze_backbone=freeze_vision_backbone,
|
| 456 |
+
)
|
| 457 |
+
return StructuralGenerativeVQA(config=config, vision_encoder=vision_encoder)
|
src/models/text_encoder.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from collections import Counter
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
TOKEN_PATTERN = re.compile(r"<image>|[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)?|[^\sA-Za-z0-9]", re.UNICODE)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BasicVQATokenizer:
|
| 14 |
+
"""
|
| 15 |
+
Tokenizer tối giản cho prototype VQA classification.
|
| 16 |
+
|
| 17 |
+
Không thay thế tokenizer LLM thật. Mục tiêu là smoke test cross-modal pipeline:
|
| 18 |
+
question_text -> input_ids/attention_mask -> SimpleTextEncoder.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
pad_token = "<pad>"
|
| 22 |
+
unk_token = "<unk>"
|
| 23 |
+
bos_token = "<bos>"
|
| 24 |
+
eos_token = "<eos>"
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
vocab: Optional[Dict[str, int]] = None,
|
| 29 |
+
lowercase: bool = True,
|
| 30 |
+
keep_image_token: bool = True,
|
| 31 |
+
):
|
| 32 |
+
self.lowercase = lowercase
|
| 33 |
+
self.keep_image_token = keep_image_token
|
| 34 |
+
|
| 35 |
+
if vocab is None:
|
| 36 |
+
vocab = {
|
| 37 |
+
self.pad_token: 0,
|
| 38 |
+
self.unk_token: 1,
|
| 39 |
+
self.bos_token: 2,
|
| 40 |
+
self.eos_token: 3,
|
| 41 |
+
"<image>": 4,
|
| 42 |
+
}
|
| 43 |
+
self.vocab = dict(vocab)
|
| 44 |
+
self.id_to_token = {idx: token for token, idx in self.vocab.items()}
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def pad_token_id(self) -> int:
|
| 48 |
+
return self.vocab[self.pad_token]
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def unk_token_id(self) -> int:
|
| 52 |
+
return self.vocab[self.unk_token]
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def bos_token_id(self) -> int:
|
| 56 |
+
return self.vocab[self.bos_token]
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def eos_token_id(self) -> int:
|
| 60 |
+
return self.vocab[self.eos_token]
|
| 61 |
+
|
| 62 |
+
def __len__(self) -> int:
|
| 63 |
+
return len(self.vocab)
|
| 64 |
+
|
| 65 |
+
def tokenize(self, text: str) -> List[str]:
|
| 66 |
+
text = text or ""
|
| 67 |
+
tokens = TOKEN_PATTERN.findall(text)
|
| 68 |
+
processed = []
|
| 69 |
+
for token in tokens:
|
| 70 |
+
if token == "<image>":
|
| 71 |
+
if self.keep_image_token:
|
| 72 |
+
processed.append(token)
|
| 73 |
+
continue
|
| 74 |
+
processed.append(token.lower() if self.lowercase else token)
|
| 75 |
+
return processed
|
| 76 |
+
|
| 77 |
+
def encode(
|
| 78 |
+
self,
|
| 79 |
+
text: str,
|
| 80 |
+
max_length: int = 64,
|
| 81 |
+
add_special_tokens: bool = True,
|
| 82 |
+
) -> List[int]:
|
| 83 |
+
tokens = self.tokenize(text)
|
| 84 |
+
ids = [self.vocab.get(token, self.unk_token_id) for token in tokens]
|
| 85 |
+
|
| 86 |
+
if add_special_tokens:
|
| 87 |
+
ids = [self.bos_token_id] + ids + [self.eos_token_id]
|
| 88 |
+
|
| 89 |
+
return ids[:max_length]
|
| 90 |
+
|
| 91 |
+
def batch_encode(
|
| 92 |
+
self,
|
| 93 |
+
texts: Sequence[str],
|
| 94 |
+
max_length: int = 64,
|
| 95 |
+
add_special_tokens: bool = True,
|
| 96 |
+
) -> Dict[str, torch.Tensor]:
|
| 97 |
+
encoded = [self.encode(text, max_length=max_length, add_special_tokens=add_special_tokens) for text in texts]
|
| 98 |
+
max_len = max((len(ids) for ids in encoded), default=1)
|
| 99 |
+
max_len = min(max_len, max_length)
|
| 100 |
+
|
| 101 |
+
input_ids = torch.full((len(texts), max_len), self.pad_token_id, dtype=torch.long)
|
| 102 |
+
attention_mask = torch.zeros((len(texts), max_len), dtype=torch.long)
|
| 103 |
+
|
| 104 |
+
for i, ids in enumerate(encoded):
|
| 105 |
+
ids = ids[:max_len]
|
| 106 |
+
input_ids[i, : len(ids)] = torch.tensor(ids, dtype=torch.long)
|
| 107 |
+
attention_mask[i, : len(ids)] = 1
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
"input_ids": input_ids,
|
| 111 |
+
"attention_mask": attention_mask,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
@classmethod
|
| 115 |
+
def build_from_texts(
|
| 116 |
+
cls,
|
| 117 |
+
texts: Iterable[str],
|
| 118 |
+
min_freq: int = 1,
|
| 119 |
+
max_vocab_size: Optional[int] = None,
|
| 120 |
+
lowercase: bool = True,
|
| 121 |
+
keep_image_token: bool = True,
|
| 122 |
+
) -> "BasicVQATokenizer":
|
| 123 |
+
tokenizer = cls(lowercase=lowercase, keep_image_token=keep_image_token)
|
| 124 |
+
counter = Counter()
|
| 125 |
+
for text in texts:
|
| 126 |
+
counter.update(tokenizer.tokenize(text))
|
| 127 |
+
|
| 128 |
+
reserved = dict(tokenizer.vocab)
|
| 129 |
+
vocab = dict(reserved)
|
| 130 |
+
max_new_tokens = None
|
| 131 |
+
if max_vocab_size is not None:
|
| 132 |
+
max_new_tokens = max(max_vocab_size - len(reserved), 0)
|
| 133 |
+
|
| 134 |
+
added = 0
|
| 135 |
+
for token, freq in counter.most_common():
|
| 136 |
+
if freq < min_freq:
|
| 137 |
+
continue
|
| 138 |
+
if token in vocab:
|
| 139 |
+
continue
|
| 140 |
+
if max_new_tokens is not None and added >= max_new_tokens:
|
| 141 |
+
break
|
| 142 |
+
vocab[token] = len(vocab)
|
| 143 |
+
added += 1
|
| 144 |
+
|
| 145 |
+
return cls(vocab=vocab, lowercase=lowercase, keep_image_token=keep_image_token)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@dataclass
|
| 149 |
+
class SimpleTextEncoderConfig:
|
| 150 |
+
vocab_size: int
|
| 151 |
+
d_model: int = 768
|
| 152 |
+
max_length: int = 64
|
| 153 |
+
pad_token_id: int = 0
|
| 154 |
+
num_layers: int = 2
|
| 155 |
+
num_heads: int = 8
|
| 156 |
+
dim_feedforward: int = 2048
|
| 157 |
+
dropout: float = 0.10
|
| 158 |
+
use_positional_embedding: bool = True
|
| 159 |
+
|
| 160 |
+
def __post_init__(self):
|
| 161 |
+
if self.vocab_size <= 0:
|
| 162 |
+
raise ValueError("vocab_size phải > 0")
|
| 163 |
+
if self.d_model <= 0:
|
| 164 |
+
raise ValueError("d_model phải > 0")
|
| 165 |
+
if self.max_length <= 0:
|
| 166 |
+
raise ValueError("max_length phải > 0")
|
| 167 |
+
if self.d_model % self.num_heads != 0:
|
| 168 |
+
raise ValueError("d_model phải chia hết cho num_heads")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class SimpleTextEncoder(nn.Module):
|
| 172 |
+
"""
|
| 173 |
+
Text encoder tối giản: Embedding + TransformerEncoder.
|
| 174 |
+
|
| 175 |
+
Input:
|
| 176 |
+
input_ids: [B, Nt]
|
| 177 |
+
attention_mask: [B, Nt], 1=valid, 0=pad
|
| 178 |
+
|
| 179 |
+
Output:
|
| 180 |
+
text_tokens: [B, Nt, D]
|
| 181 |
+
pooled_text: [B, D]
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
def __init__(self, config: SimpleTextEncoderConfig):
|
| 185 |
+
super().__init__()
|
| 186 |
+
self.config = config
|
| 187 |
+
|
| 188 |
+
self.token_embedding = nn.Embedding(
|
| 189 |
+
config.vocab_size,
|
| 190 |
+
config.d_model,
|
| 191 |
+
padding_idx=config.pad_token_id,
|
| 192 |
+
)
|
| 193 |
+
self.position_embedding = None
|
| 194 |
+
if config.use_positional_embedding:
|
| 195 |
+
self.position_embedding = nn.Embedding(config.max_length, config.d_model)
|
| 196 |
+
|
| 197 |
+
encoder_layer = nn.TransformerEncoderLayer(
|
| 198 |
+
d_model=config.d_model,
|
| 199 |
+
nhead=config.num_heads,
|
| 200 |
+
dim_feedforward=config.dim_feedforward,
|
| 201 |
+
dropout=config.dropout,
|
| 202 |
+
activation="gelu",
|
| 203 |
+
batch_first=True,
|
| 204 |
+
norm_first=False,
|
| 205 |
+
)
|
| 206 |
+
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=config.num_layers)
|
| 207 |
+
self.output_norm = nn.LayerNorm(config.d_model)
|
| 208 |
+
|
| 209 |
+
def forward(
|
| 210 |
+
self,
|
| 211 |
+
input_ids: torch.Tensor,
|
| 212 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 213 |
+
return_dict: bool = True,
|
| 214 |
+
):
|
| 215 |
+
if input_ids.ndim != 2:
|
| 216 |
+
raise ValueError(f"input_ids phải có shape [B, Nt], nhận {tuple(input_ids.shape)}")
|
| 217 |
+
|
| 218 |
+
batch_size, seq_len = input_ids.shape
|
| 219 |
+
if seq_len > self.config.max_length:
|
| 220 |
+
input_ids = input_ids[:, : self.config.max_length]
|
| 221 |
+
seq_len = self.config.max_length
|
| 222 |
+
if attention_mask is not None:
|
| 223 |
+
attention_mask = attention_mask[:, : self.config.max_length]
|
| 224 |
+
|
| 225 |
+
if attention_mask is None:
|
| 226 |
+
attention_mask = (input_ids != self.config.pad_token_id).long()
|
| 227 |
+
attention_mask = attention_mask.to(device=input_ids.device)
|
| 228 |
+
|
| 229 |
+
x = self.token_embedding(input_ids)
|
| 230 |
+
if self.position_embedding is not None:
|
| 231 |
+
positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0).expand(batch_size, -1)
|
| 232 |
+
x = x + self.position_embedding(positions)
|
| 233 |
+
|
| 234 |
+
key_padding_mask = attention_mask == 0
|
| 235 |
+
text_tokens = self.encoder(x, src_key_padding_mask=key_padding_mask)
|
| 236 |
+
text_tokens = self.output_norm(text_tokens)
|
| 237 |
+
|
| 238 |
+
pooled_text = self.masked_mean_pool(text_tokens, attention_mask)
|
| 239 |
+
|
| 240 |
+
if not return_dict:
|
| 241 |
+
return text_tokens
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
"text_tokens": text_tokens,
|
| 245 |
+
"pooled_text": pooled_text,
|
| 246 |
+
"attention_mask": attention_mask,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
@staticmethod
|
| 250 |
+
def masked_mean_pool(tokens: torch.Tensor, attention_mask: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
|
| 251 |
+
mask = attention_mask.to(device=tokens.device, dtype=tokens.dtype).unsqueeze(-1)
|
| 252 |
+
summed = (tokens * mask).sum(dim=1)
|
| 253 |
+
denom = mask.sum(dim=1).clamp_min(eps)
|
| 254 |
+
return summed / denom
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def build_text_encoder_from_questions(
|
| 258 |
+
questions: Sequence[str],
|
| 259 |
+
d_model: int = 768,
|
| 260 |
+
max_length: int = 64,
|
| 261 |
+
min_freq: int = 1,
|
| 262 |
+
max_vocab_size: Optional[int] = 30000,
|
| 263 |
+
num_layers: int = 2,
|
| 264 |
+
num_heads: int = 8,
|
| 265 |
+
dropout: float = 0.10,
|
| 266 |
+
) -> Tuple[BasicVQATokenizer, SimpleTextEncoder]:
|
| 267 |
+
tokenizer = BasicVQATokenizer.build_from_texts(
|
| 268 |
+
questions,
|
| 269 |
+
min_freq=min_freq,
|
| 270 |
+
max_vocab_size=max_vocab_size,
|
| 271 |
+
)
|
| 272 |
+
config = SimpleTextEncoderConfig(
|
| 273 |
+
vocab_size=len(tokenizer),
|
| 274 |
+
d_model=d_model,
|
| 275 |
+
max_length=max_length,
|
| 276 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 277 |
+
num_layers=num_layers,
|
| 278 |
+
num_heads=num_heads,
|
| 279 |
+
dropout=dropout,
|
| 280 |
+
)
|
| 281 |
+
return tokenizer, SimpleTextEncoder(config)
|
src/models/vision_encoder.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Any, Dict, Optional, Tuple
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
|
| 7 |
+
from src.models.fusion import StructuralFusionConfig, StructuralVisualFusion
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class VisionEncoderConfig:
|
| 12 |
+
"""
|
| 13 |
+
Cấu hình cho vision encoder wrapper.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
backbone_name: Tên backbone. Ưu tiên timm nếu có, ví dụ
|
| 17 |
+
"vit_base_patch16_224".
|
| 18 |
+
pretrained: Dùng pretrained weights nếu backend hỗ trợ.
|
| 19 |
+
image_size: Kích thước ảnh input HxW.
|
| 20 |
+
patch_size: Patch size của ViT. Với 224/16 -> 14x14 -> 196 tokens.
|
| 21 |
+
d_model: Hidden dimension output patch tokens.
|
| 22 |
+
freeze_backbone: Nếu True, freeze ViT backbone để train fusion trước.
|
| 23 |
+
backend: "auto", "timm", hoặc "torchvision".
|
| 24 |
+
drop_cls_token: Nếu True, bỏ CLS token để chỉ giữ 196 patch tokens.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
backbone_name: str = "vit_base_patch16_224"
|
| 28 |
+
pretrained: bool = True
|
| 29 |
+
image_size: Tuple[int, int] = (224, 224)
|
| 30 |
+
patch_size: int = 16
|
| 31 |
+
d_model: int = 768
|
| 32 |
+
freeze_backbone: bool = True
|
| 33 |
+
backend: str = "auto"
|
| 34 |
+
drop_cls_token: bool = True
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def num_patches(self) -> int:
|
| 38 |
+
return (self.image_size[0] // self.patch_size) * (self.image_size[1] // self.patch_size)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PatchVisionEncoder(nn.Module):
|
| 42 |
+
"""
|
| 43 |
+
Wrapper lấy patch tokens từ pretrained ViT.
|
| 44 |
+
|
| 45 |
+
Input:
|
| 46 |
+
image: [B, 3, H, W]
|
| 47 |
+
|
| 48 |
+
Output:
|
| 49 |
+
patch_tokens: [B, N, D]
|
| 50 |
+
|
| 51 |
+
Backend:
|
| 52 |
+
- timm: ưu tiên vì hỗ trợ forward_features tiện.
|
| 53 |
+
- torchvision: fallback cho vit_b_16 nếu không có timm.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, config: VisionEncoderConfig):
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.config = config
|
| 59 |
+
self.backbone, self.backend, inferred_dim = self._build_backbone(config)
|
| 60 |
+
|
| 61 |
+
self.output_projection = nn.Identity()
|
| 62 |
+
if inferred_dim != config.d_model:
|
| 63 |
+
self.output_projection = nn.Linear(inferred_dim, config.d_model)
|
| 64 |
+
|
| 65 |
+
if config.freeze_backbone:
|
| 66 |
+
self.freeze_backbone()
|
| 67 |
+
|
| 68 |
+
def forward(self, image: torch.Tensor) -> torch.Tensor:
|
| 69 |
+
self._validate_image(image)
|
| 70 |
+
|
| 71 |
+
if self.backend == "timm":
|
| 72 |
+
tokens = self._forward_timm(image)
|
| 73 |
+
elif self.backend == "torchvision":
|
| 74 |
+
tokens = self._forward_torchvision(image)
|
| 75 |
+
else:
|
| 76 |
+
raise RuntimeError(f"Backend không hỗ trợ: {self.backend}")
|
| 77 |
+
|
| 78 |
+
tokens = self._ensure_patch_tokens(tokens)
|
| 79 |
+
tokens = self.output_projection(tokens)
|
| 80 |
+
return tokens
|
| 81 |
+
|
| 82 |
+
def freeze_backbone(self) -> None:
|
| 83 |
+
for param in self.backbone.parameters():
|
| 84 |
+
param.requires_grad = False
|
| 85 |
+
|
| 86 |
+
def unfreeze_backbone(self) -> None:
|
| 87 |
+
for param in self.backbone.parameters():
|
| 88 |
+
param.requires_grad = True
|
| 89 |
+
|
| 90 |
+
def unfreeze_last_blocks(self, num_blocks: int = 2) -> None:
|
| 91 |
+
"""Unfreeze vài block cuối nếu backend expose blocks/encoder.layers."""
|
| 92 |
+
self.freeze_backbone()
|
| 93 |
+
if num_blocks <= 0:
|
| 94 |
+
return
|
| 95 |
+
|
| 96 |
+
if hasattr(self.backbone, "blocks"):
|
| 97 |
+
blocks = self.backbone.blocks
|
| 98 |
+
for block in blocks[-num_blocks:]:
|
| 99 |
+
for param in block.parameters():
|
| 100 |
+
param.requires_grad = True
|
| 101 |
+
elif hasattr(self.backbone, "encoder") and hasattr(self.backbone.encoder, "layers"):
|
| 102 |
+
layers = self.backbone.encoder.layers
|
| 103 |
+
for layer in list(layers)[-num_blocks:]:
|
| 104 |
+
for param in layer.parameters():
|
| 105 |
+
param.requires_grad = True
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def _validate_image(image: torch.Tensor) -> None:
|
| 109 |
+
if not torch.is_tensor(image):
|
| 110 |
+
raise TypeError("image phải là torch.Tensor")
|
| 111 |
+
if image.ndim != 4:
|
| 112 |
+
raise ValueError(f"image phải có shape [B, 3, H, W], nhận {tuple(image.shape)}")
|
| 113 |
+
if image.shape[1] != 3:
|
| 114 |
+
raise ValueError(f"image channel phải là 3 RGB, nhận C={image.shape[1]}")
|
| 115 |
+
|
| 116 |
+
def _build_backbone(self, config: VisionEncoderConfig):
|
| 117 |
+
backend = config.backend.lower()
|
| 118 |
+
if backend not in {"auto", "timm", "torchvision"}:
|
| 119 |
+
raise ValueError("backend phải là 'auto', 'timm', hoặc 'torchvision'")
|
| 120 |
+
|
| 121 |
+
if backend in {"auto", "timm"}:
|
| 122 |
+
try:
|
| 123 |
+
import timm
|
| 124 |
+
|
| 125 |
+
backbone = timm.create_model(
|
| 126 |
+
config.backbone_name,
|
| 127 |
+
pretrained=config.pretrained,
|
| 128 |
+
num_classes=0,
|
| 129 |
+
)
|
| 130 |
+
inferred_dim = getattr(backbone, "num_features", config.d_model)
|
| 131 |
+
return backbone, "timm", int(inferred_dim)
|
| 132 |
+
except ImportError:
|
| 133 |
+
if backend == "timm":
|
| 134 |
+
raise ImportError(
|
| 135 |
+
"Bạn chọn backend='timm' nhưng chưa cài timm. Cài bằng: pip install timm"
|
| 136 |
+
)
|
| 137 |
+
except Exception:
|
| 138 |
+
if backend == "timm":
|
| 139 |
+
raise
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
from torchvision.models import ViT_B_16_Weights, vit_b_16
|
| 143 |
+
|
| 144 |
+
weights = ViT_B_16_Weights.DEFAULT if config.pretrained else None
|
| 145 |
+
backbone = vit_b_16(weights=weights)
|
| 146 |
+
inferred_dim = backbone.hidden_dim
|
| 147 |
+
return backbone, "torchvision", int(inferred_dim)
|
| 148 |
+
except Exception as exc:
|
| 149 |
+
raise RuntimeError(
|
| 150 |
+
"Không build được ViT backend. Hãy cài timm bằng `pip install timm` "
|
| 151 |
+
"hoặc kiểm tra torchvision."
|
| 152 |
+
) from exc
|
| 153 |
+
|
| 154 |
+
def _forward_timm(self, image: torch.Tensor) -> torch.Tensor:
|
| 155 |
+
features = self.backbone.forward_features(image)
|
| 156 |
+
|
| 157 |
+
if isinstance(features, dict):
|
| 158 |
+
for key in ("x", "tokens", "last_hidden_state"):
|
| 159 |
+
if key in features:
|
| 160 |
+
features = features[key]
|
| 161 |
+
break
|
| 162 |
+
else:
|
| 163 |
+
raise RuntimeError(f"Không nhận diện được output dict từ timm: {features.keys()}")
|
| 164 |
+
|
| 165 |
+
return features
|
| 166 |
+
|
| 167 |
+
def _forward_torchvision(self, image: torch.Tensor) -> torch.Tensor:
|
| 168 |
+
# Logic gần giống torchvision VisionTransformer._process_input + encoder.
|
| 169 |
+
x = self.backbone._process_input(image)
|
| 170 |
+
batch_size = x.shape[0]
|
| 171 |
+
cls_token = self.backbone.class_token.expand(batch_size, -1, -1)
|
| 172 |
+
x = torch.cat([cls_token, x], dim=1)
|
| 173 |
+
x = self.backbone.encoder(x)
|
| 174 |
+
return x
|
| 175 |
+
|
| 176 |
+
def _ensure_patch_tokens(self, tokens: torch.Tensor) -> torch.Tensor:
|
| 177 |
+
if tokens.ndim != 3:
|
| 178 |
+
raise ValueError(f"ViT output phải có shape [B, N, D], nhận {tuple(tokens.shape)}")
|
| 179 |
+
|
| 180 |
+
expected_patches = self.config.num_patches
|
| 181 |
+
num_tokens = tokens.shape[1]
|
| 182 |
+
|
| 183 |
+
if self.config.drop_cls_token and num_tokens == expected_patches + 1:
|
| 184 |
+
tokens = tokens[:, 1:, :]
|
| 185 |
+
elif num_tokens == expected_patches:
|
| 186 |
+
pass
|
| 187 |
+
elif num_tokens > expected_patches:
|
| 188 |
+
tokens = tokens[:, -expected_patches:, :]
|
| 189 |
+
else:
|
| 190 |
+
raise ValueError(
|
| 191 |
+
f"Số token ViT={num_tokens} nhỏ hơn expected patches={expected_patches}. "
|
| 192 |
+
"Kiểm tra image_size/patch_size/backbone."
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return tokens
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class GlobalFeatureProjector(nn.Module):
|
| 199 |
+
"""
|
| 200 |
+
Chiếu global structural features [B, G] thành global token [B, 1, D].
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
def __init__(
|
| 204 |
+
self,
|
| 205 |
+
global_feature_dim: int,
|
| 206 |
+
d_model: int,
|
| 207 |
+
hidden_dim: Optional[int] = None,
|
| 208 |
+
dropout: float = 0.10,
|
| 209 |
+
use_layer_norm: bool = True,
|
| 210 |
+
):
|
| 211 |
+
super().__init__()
|
| 212 |
+
if hidden_dim is None:
|
| 213 |
+
hidden_dim = max(d_model // 2, global_feature_dim * 4)
|
| 214 |
+
|
| 215 |
+
self.input_norm = nn.LayerNorm(global_feature_dim) if use_layer_norm else nn.Identity()
|
| 216 |
+
self.projector = nn.Sequential(
|
| 217 |
+
nn.Linear(global_feature_dim, hidden_dim),
|
| 218 |
+
nn.GELU(),
|
| 219 |
+
nn.Dropout(dropout),
|
| 220 |
+
nn.Linear(hidden_dim, d_model),
|
| 221 |
+
nn.Dropout(dropout),
|
| 222 |
+
)
|
| 223 |
+
self.output_norm = nn.LayerNorm(d_model) if use_layer_norm else nn.Identity()
|
| 224 |
+
|
| 225 |
+
def forward(self, global_features: torch.Tensor) -> torch.Tensor:
|
| 226 |
+
if not torch.is_tensor(global_features):
|
| 227 |
+
global_features = torch.as_tensor(global_features)
|
| 228 |
+
if global_features.ndim != 2:
|
| 229 |
+
raise ValueError(
|
| 230 |
+
f"global_features phải có shape [B, G], nhận {tuple(global_features.shape)}"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
token = self.projector(self.input_norm(global_features.float()))
|
| 234 |
+
token = self.output_norm(token)
|
| 235 |
+
return token.unsqueeze(1)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class StructuralVisionEncoder(nn.Module):
|
| 239 |
+
"""
|
| 240 |
+
End-to-end vision side:
|
| 241 |
+
|
| 242 |
+
image -> ViT -> patch_tokens
|
| 243 |
+
patch_tokens + prior_mask + topo_features -> StructuralVisualFusion
|
| 244 |
+
global_features -> global structural token
|
| 245 |
+
concat -> visual_context
|
| 246 |
+
|
| 247 |
+
Output:
|
| 248 |
+
visual_context: [B, 197, D] nếu dùng global token
|
| 249 |
+
fused_tokens: [B, 196, D]
|
| 250 |
+
patch_tokens: [B, 196, D]
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
+
def __init__(
|
| 254 |
+
self,
|
| 255 |
+
vision_config: VisionEncoderConfig,
|
| 256 |
+
topo_feature_dim: int = 12,
|
| 257 |
+
global_feature_dim: int = 8,
|
| 258 |
+
use_global_token: bool = True,
|
| 259 |
+
fusion_kwargs: Optional[Dict[str, Any]] = None,
|
| 260 |
+
):
|
| 261 |
+
super().__init__()
|
| 262 |
+
fusion_kwargs = fusion_kwargs or {}
|
| 263 |
+
|
| 264 |
+
self.vision_config = vision_config
|
| 265 |
+
self.use_global_token = use_global_token
|
| 266 |
+
self.patch_encoder = PatchVisionEncoder(vision_config)
|
| 267 |
+
|
| 268 |
+
fusion_config = StructuralFusionConfig(
|
| 269 |
+
d_model=vision_config.d_model,
|
| 270 |
+
topo_feature_dim=topo_feature_dim,
|
| 271 |
+
**fusion_kwargs,
|
| 272 |
+
)
|
| 273 |
+
self.fusion = StructuralVisualFusion(fusion_config)
|
| 274 |
+
|
| 275 |
+
self.global_projector = None
|
| 276 |
+
if use_global_token:
|
| 277 |
+
self.global_projector = GlobalFeatureProjector(
|
| 278 |
+
global_feature_dim=global_feature_dim,
|
| 279 |
+
d_model=vision_config.d_model,
|
| 280 |
+
dropout=fusion_config.dropout,
|
| 281 |
+
use_layer_norm=fusion_config.use_layer_norm,
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
def forward(
|
| 285 |
+
self,
|
| 286 |
+
image: torch.Tensor,
|
| 287 |
+
prior_mask: Optional[torch.Tensor] = None,
|
| 288 |
+
topo_features: Optional[torch.Tensor] = None,
|
| 289 |
+
global_features: Optional[torch.Tensor] = None,
|
| 290 |
+
return_diagnostics: bool = True,
|
| 291 |
+
):
|
| 292 |
+
patch_tokens = self.patch_encoder(image)
|
| 293 |
+
|
| 294 |
+
fusion_out = self.fusion(
|
| 295 |
+
visual_tokens=patch_tokens,
|
| 296 |
+
prior_mask=prior_mask,
|
| 297 |
+
topo_features=topo_features,
|
| 298 |
+
return_diagnostics=True,
|
| 299 |
+
)
|
| 300 |
+
fused_tokens = fusion_out["fused_tokens"]
|
| 301 |
+
|
| 302 |
+
global_token = None
|
| 303 |
+
visual_context = fused_tokens
|
| 304 |
+
if self.use_global_token:
|
| 305 |
+
if global_features is None:
|
| 306 |
+
raise ValueError("use_global_token=True nhưng global_features=None")
|
| 307 |
+
global_features = global_features.to(device=fused_tokens.device, dtype=fused_tokens.dtype)
|
| 308 |
+
global_token = self.global_projector(global_features)
|
| 309 |
+
visual_context = torch.cat([global_token, fused_tokens], dim=1)
|
| 310 |
+
|
| 311 |
+
if not return_diagnostics:
|
| 312 |
+
return visual_context
|
| 313 |
+
|
| 314 |
+
return {
|
| 315 |
+
"visual_context": visual_context,
|
| 316 |
+
"patch_tokens": patch_tokens,
|
| 317 |
+
"fused_tokens": fused_tokens,
|
| 318 |
+
"global_token": global_token,
|
| 319 |
+
**fusion_out,
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def build_structural_vision_encoder(
|
| 324 |
+
d_model: int = 768,
|
| 325 |
+
topo_feature_dim: int = 12,
|
| 326 |
+
global_feature_dim: int = 8,
|
| 327 |
+
backbone_name: str = "vit_base_patch16_224",
|
| 328 |
+
pretrained: bool = True,
|
| 329 |
+
freeze_backbone: bool = True,
|
| 330 |
+
backend: str = "auto",
|
| 331 |
+
use_global_token: bool = True,
|
| 332 |
+
**fusion_kwargs,
|
| 333 |
+
) -> StructuralVisionEncoder:
|
| 334 |
+
vision_config = VisionEncoderConfig(
|
| 335 |
+
backbone_name=backbone_name,
|
| 336 |
+
pretrained=pretrained,
|
| 337 |
+
d_model=d_model,
|
| 338 |
+
freeze_backbone=freeze_backbone,
|
| 339 |
+
backend=backend,
|
| 340 |
+
)
|
| 341 |
+
return StructuralVisionEncoder(
|
| 342 |
+
vision_config=vision_config,
|
| 343 |
+
topo_feature_dim=topo_feature_dim,
|
| 344 |
+
global_feature_dim=global_feature_dim,
|
| 345 |
+
use_global_token=use_global_token,
|
| 346 |
+
fusion_kwargs=fusion_kwargs,
|
| 347 |
+
)
|
src/postprocessing/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-processing helpers copied for the HF Task 1 submission."""
|
| 2 |
+
|
| 3 |
+
from .answer_normalization import infer_question_type_for_normalization, normalize_prediction
|
| 4 |
+
|
| 5 |
+
__all__ = ["normalize_prediction", "infer_question_type_for_normalization"]
|
src/postprocessing/answer_normalization.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal answer cleanup for Medico 2026 Task 1 submissions.
|
| 2 |
+
|
| 3 |
+
Full-test rescoring showed that semantic or qtype-specific normalization can
|
| 4 |
+
reduce the official text metrics. Keep this module intentionally conservative:
|
| 5 |
+
only remove obvious formatting noise and never rewrite the medical meaning.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
_SPACE_RE = re.compile(r"\s+")
|
| 13 |
+
_LEADING_ANSWER_RE = re.compile(r"^\s*(?:answer\s*[:\-]\s*)+", re.IGNORECASE)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def compact_spaces(text: str) -> str:
|
| 17 |
+
return _SPACE_RE.sub(" ", str(text or "")).strip()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def normalize_prediction(prediction: str, question: str = "") -> str:
|
| 21 |
+
"""Return a minimally cleaned prediction without semantic rewrites."""
|
| 22 |
+
|
| 23 |
+
del question # The final Task 1 normalizer is intentionally question-agnostic.
|
| 24 |
+
text = compact_spaces(prediction)
|
| 25 |
+
text = _LEADING_ANSWER_RE.sub("", text)
|
| 26 |
+
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
|
| 27 |
+
return compact_spaces(text).strip()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def infer_question_type_for_normalization(question: str) -> str:
|
| 31 |
+
"""Compatibility shim for older imports; not used for rewriting."""
|
| 32 |
+
|
| 33 |
+
q = str(question or "").lower()
|
| 34 |
+
if any(marker in q for marker in ["how many", "number of", "count"]):
|
| 35 |
+
return "numerical_count"
|
| 36 |
+
if q.startswith(("is ", "are ", "was ", "were ", "do ", "does ", "did ", "can ", "has ", "have ")):
|
| 37 |
+
return "yes_no"
|
| 38 |
+
return "other"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
__all__ = ["normalize_prediction", "infer_question_type_for_normalization"]
|
submission_task1.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediaEval Medico 2026 Task 1 submission entry point.
|
| 2 |
+
|
| 3 |
+
This is a repository-ready skeleton for the HuggingFace model repo:
|
| 4 |
+
minhquang47/medico2026-task1-qwen3b-a3
|
| 5 |
+
|
| 6 |
+
The official `medvqa` validator imports/runs this script from the repo root.
|
| 7 |
+
Keep all paths relative to this file.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict, Iterable, List, Optional
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
REPO_DIR = Path(__file__).resolve().parent
|
| 23 |
+
if str(REPO_DIR) not in sys.path:
|
| 24 |
+
sys.path.insert(0, str(REPO_DIR))
|
| 25 |
+
|
| 26 |
+
# If the full project source is copied under repo root, these imports should work.
|
| 27 |
+
# If only the normalizer is needed for an early validation smoke test, the model
|
| 28 |
+
# loading error is raised lazily in `load_model`.
|
| 29 |
+
try:
|
| 30 |
+
from src.models.structural_vqa_generative import build_structural_generative_vqa
|
| 31 |
+
except Exception: # pragma: no cover - validator environment dependent
|
| 32 |
+
build_structural_generative_vqa = None
|
| 33 |
+
|
| 34 |
+
from src.postprocessing.answer_normalization import normalize_prediction
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
CHECKPOINT_CANDIDATES = [
|
| 38 |
+
REPO_DIR / "checkpoints" / "final.pt",
|
| 39 |
+
REPO_DIR / "checkpoints" / "epoch_3.pt",
|
| 40 |
+
REPO_DIR / "checkpoints" / "epoch_4.pt",
|
| 41 |
+
REPO_DIR / "checkpoints" / "last.pt",
|
| 42 |
+
REPO_DIR / "model.pt",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
DEFAULT_IMAGE_SIZE = (224, 224)
|
| 46 |
+
DEFAULT_GRID_SIZE = (14, 14)
|
| 47 |
+
DEFAULT_TOPO_FEATURE_DIM = 12
|
| 48 |
+
DEFAULT_GLOBAL_FEATURE_DIM = 8
|
| 49 |
+
|
| 50 |
+
_MODEL = None
|
| 51 |
+
_DEVICE = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _find_checkpoint() -> Path:
|
| 55 |
+
for path in CHECKPOINT_CANDIDATES:
|
| 56 |
+
if path.exists():
|
| 57 |
+
return path
|
| 58 |
+
raise FileNotFoundError(
|
| 59 |
+
"No checkpoint found. Expected one of: "
|
| 60 |
+
+ ", ".join(str(path.relative_to(REPO_DIR)) for path in CHECKPOINT_CANDIDATES)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _select_device() -> torch.device:
|
| 65 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _safe_load_checkpoint(checkpoint_path: Path) -> Dict[str, Any]:
|
| 69 |
+
"""Load a checkpoint while tolerating optimizer/RNG pickle incompatibilities."""
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
return torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 73 |
+
except TypeError:
|
| 74 |
+
loaded = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
|
| 75 |
+
if isinstance(loaded, dict) and "model_state_dict" in loaded:
|
| 76 |
+
return loaded
|
| 77 |
+
return {
|
| 78 |
+
"model_state_dict": loaded,
|
| 79 |
+
"args": {
|
| 80 |
+
"llm_name_or_path": "Qwen/Qwen2.5-3B-Instruct",
|
| 81 |
+
"vision_backend": "timm",
|
| 82 |
+
"freeze_llm": True,
|
| 83 |
+
"use_lora": True,
|
| 84 |
+
"lora_r": 16,
|
| 85 |
+
"lora_alpha": 32,
|
| 86 |
+
"lora_dropout": 0.05,
|
| 87 |
+
"lora_target_modules": "q_proj,v_proj",
|
| 88 |
+
"use_ot": True,
|
| 89 |
+
"use_ot_fusion": True,
|
| 90 |
+
"ot_fusion_mode": "prefix",
|
| 91 |
+
"ot_fusion_dropout": 0.10,
|
| 92 |
+
"use_prior_as_ot_target": True,
|
| 93 |
+
"use_topological_loss": True,
|
| 94 |
+
"use_prior_align_loss": True,
|
| 95 |
+
"use_global_topo_loss": True,
|
| 96 |
+
"use_patch_topo_loss": False,
|
| 97 |
+
},
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _load_model_from_checkpoint(checkpoint_path: Path, device: torch.device):
|
| 102 |
+
if build_structural_generative_vqa is None:
|
| 103 |
+
raise ImportError(
|
| 104 |
+
"Could not import src.models.structural_vqa_generative. "
|
| 105 |
+
"Copy the project src/ directory into the HF repo root."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
checkpoint = _safe_load_checkpoint(checkpoint_path)
|
| 109 |
+
train_args = checkpoint.get("args", {})
|
| 110 |
+
config = {**train_args}
|
| 111 |
+
config.update(
|
| 112 |
+
{
|
| 113 |
+
"llm_name_or_path": train_args.get("llm_name_or_path", "Qwen/Qwen2.5-3B-Instruct"),
|
| 114 |
+
"vision_pretrained": False,
|
| 115 |
+
"vision_backend": train_args.get("vision_backend", "timm"),
|
| 116 |
+
"freeze_vision_backbone": True,
|
| 117 |
+
"freeze_llm": train_args.get("freeze_llm", True),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
allowed = {
|
| 121 |
+
"llm_name_or_path",
|
| 122 |
+
"vision_pretrained",
|
| 123 |
+
"vision_backend",
|
| 124 |
+
"freeze_vision_backbone",
|
| 125 |
+
"freeze_llm",
|
| 126 |
+
"max_question_length",
|
| 127 |
+
"max_answer_length",
|
| 128 |
+
"use_lora",
|
| 129 |
+
"lora_r",
|
| 130 |
+
"lora_alpha",
|
| 131 |
+
"lora_dropout",
|
| 132 |
+
"lora_target_modules",
|
| 133 |
+
"ot_loss_weight",
|
| 134 |
+
"use_ot",
|
| 135 |
+
"use_ot_fusion",
|
| 136 |
+
"ot_fusion_mode",
|
| 137 |
+
"ot_fusion_dropout",
|
| 138 |
+
"use_prior_as_ot_target",
|
| 139 |
+
"prior_ot_global_mass",
|
| 140 |
+
"use_topological_loss",
|
| 141 |
+
"use_prior_align_loss",
|
| 142 |
+
"use_global_topo_loss",
|
| 143 |
+
"use_patch_topo_loss",
|
| 144 |
+
"prior_loss_weight",
|
| 145 |
+
"global_topo_loss_weight",
|
| 146 |
+
"patch_topo_loss_weight",
|
| 147 |
+
}
|
| 148 |
+
build_kwargs = {key: value for key, value in config.items() if key in allowed}
|
| 149 |
+
model = build_structural_generative_vqa(**build_kwargs).to(device)
|
| 150 |
+
model.load_state_dict(checkpoint["model_state_dict"], strict=False)
|
| 151 |
+
model.eval()
|
| 152 |
+
return model
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def load_model():
|
| 156 |
+
"""Load and cache the VQA model."""
|
| 157 |
+
|
| 158 |
+
global _MODEL, _DEVICE
|
| 159 |
+
if _MODEL is None:
|
| 160 |
+
_DEVICE = _select_device()
|
| 161 |
+
_MODEL = _load_model_from_checkpoint(_find_checkpoint(), _DEVICE)
|
| 162 |
+
return _MODEL
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _pil_to_tensor(image: Image.Image) -> torch.Tensor:
|
| 166 |
+
image = image.convert("RGB").resize(DEFAULT_IMAGE_SIZE, Image.BILINEAR)
|
| 167 |
+
arr = np.asarray(image).astype(np.float32) / 255.0
|
| 168 |
+
arr = np.transpose(arr, (2, 0, 1))
|
| 169 |
+
return torch.from_numpy(arr).float().unsqueeze(0)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _default_structural_tensors(batch_size: int = 1) -> Dict[str, torch.Tensor]:
|
| 173 |
+
"""Fallback structural tensors for hidden validation without manifests.
|
| 174 |
+
|
| 175 |
+
The trained model expects prior/topological features. Official validation is
|
| 176 |
+
likely image+question only, so this fallback keeps inference runnable. If a
|
| 177 |
+
lightweight structural feature extractor is later added to the repo, replace
|
| 178 |
+
this function with real per-image features.
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
grid_h, grid_w = DEFAULT_GRID_SIZE
|
| 182 |
+
prior = torch.ones(batch_size, 1, grid_h, grid_w, dtype=torch.float32)
|
| 183 |
+
prior = prior / prior.flatten(1).sum(dim=1).view(batch_size, 1, 1, 1).clamp_min(1e-6)
|
| 184 |
+
return {
|
| 185 |
+
"prior_mask": prior,
|
| 186 |
+
"topo_features": torch.zeros(batch_size, DEFAULT_TOPO_FEATURE_DIM, dtype=torch.float32),
|
| 187 |
+
"global_features": torch.zeros(batch_size, DEFAULT_GLOBAL_FEATURE_DIM, dtype=torch.float32),
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def predict_one(image: Image.Image, question: str, max_new_tokens: int = 48) -> str:
|
| 192 |
+
model = load_model()
|
| 193 |
+
device = _DEVICE or _select_device()
|
| 194 |
+
image_tensor = _pil_to_tensor(image).to(device)
|
| 195 |
+
structural = {key: value.to(device) for key, value in _default_structural_tensors(1).items()}
|
| 196 |
+
|
| 197 |
+
with torch.inference_mode():
|
| 198 |
+
prediction = model.generate(
|
| 199 |
+
image=image_tensor,
|
| 200 |
+
prior_mask=structural["prior_mask"],
|
| 201 |
+
topo_features=structural["topo_features"],
|
| 202 |
+
global_features=structural["global_features"],
|
| 203 |
+
question_text=[question],
|
| 204 |
+
max_new_tokens=max_new_tokens,
|
| 205 |
+
)[0]
|
| 206 |
+
return normalize_prediction(prediction, question)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def predict(batch: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 210 |
+
"""Generic batch interface for validators that pass dict records.
|
| 211 |
+
|
| 212 |
+
Each record is expected to contain an `image` PIL object/path and `question`.
|
| 213 |
+
The return keeps IDs if present and always includes `answer`.
|
| 214 |
+
"""
|
| 215 |
+
|
| 216 |
+
outputs = []
|
| 217 |
+
for record in batch:
|
| 218 |
+
image = record.get("image")
|
| 219 |
+
if isinstance(image, (str, os.PathLike)):
|
| 220 |
+
image = Image.open(image)
|
| 221 |
+
if not isinstance(image, Image.Image):
|
| 222 |
+
raise TypeError("record['image'] must be a PIL.Image.Image or image path")
|
| 223 |
+
question = str(record.get("question", ""))
|
| 224 |
+
answer = predict_one(image, question)
|
| 225 |
+
out = {key: record[key] for key in ("id", "img_id", "question") if key in record}
|
| 226 |
+
out["answer"] = answer
|
| 227 |
+
outputs.append(out)
|
| 228 |
+
return outputs
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class MedicoTask1Model:
|
| 232 |
+
"""Small callable wrapper for template-style validators."""
|
| 233 |
+
|
| 234 |
+
def __call__(self, image: Image.Image, question: str) -> str:
|
| 235 |
+
return predict_one(image, question)
|
| 236 |
+
|
| 237 |
+
def predict(self, image: Image.Image, question: str) -> str:
|
| 238 |
+
return predict_one(image, question)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def load():
|
| 242 |
+
"""Factory hook used by some submission templates."""
|
| 243 |
+
|
| 244 |
+
load_model()
|
| 245 |
+
return MedicoTask1Model()
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def main():
|
| 249 |
+
# Local smoke entry point. Official validation may import `load` or `predict`.
|
| 250 |
+
print("Medico 2026 Task 1 submission script is importable.")
|
| 251 |
+
print("Repo:", REPO_DIR)
|
| 252 |
+
print("Checkpoint exists:", any(path.exists() for path in CHECKPOINT_CANDIDATES))
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
if __name__ == "__main__":
|
| 256 |
+
main()
|