Spaces:
Sleeping
Sleeping
Add Gradio app for inference and model selection
Browse files- Introduced app.py for a Gradio interface allowing users to upload images and select between baseline and GNN models for inference.
- Implemented ModelRegistry for caching loaded models and managing checkpoints.
- Added README_SPACE.md to guide users on setting up the Hugging Face Space with the necessary files and configurations.
- Created requirements-space.txt for dependencies specific to the Gradio app.
- Developed scripts for single-image inference and comparison of baseline vs GNN model metrics.
- Enhanced existing README.md with updated training and evaluation instructions for both models.
- README.md +17 -6
- README_SPACE.md +45 -0
- app.py +112 -0
- requirements-space.txt +9 -0
- requirements.txt +1 -0
- scripts/compare_baseline_vs_gnn.py +57 -0
- scripts/infer.py +81 -0
- scripts/train_baseline.py +3 -3
- scripts/train_gnn.py +361 -0
- src/infer/model_adapters.py +171 -0
- src/infer/pipeline.py +57 -0
- src/train/graph_builder.py +67 -0
- src/train/graph_models.py +126 -0
README.md
CHANGED
|
@@ -105,14 +105,25 @@ Important: the processing pipeline is strict chair-only (`primary_object_class:
|
|
| 105 |
|
| 106 |
### 2) Train/Evaluate Models
|
| 107 |
|
| 108 |
-
|
| 109 |
|
| 110 |
-
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
## Expected Outputs
|
| 118 |
|
|
|
|
| 105 |
|
| 106 |
### 2) Train/Evaluate Models
|
| 107 |
|
| 108 |
+
#### Baseline (CLIP/DINO + MLP)
|
| 109 |
|
| 110 |
+
- Smoke run (quick sanity check):
|
| 111 |
|
| 112 |
+
`python scripts/train_baseline.py --run-name smoke_baseline --mode smoke --device auto`
|
| 113 |
+
|
| 114 |
+
- Full run:
|
| 115 |
+
|
| 116 |
+
`python scripts/train_baseline.py --run-name baseline_full --mode full --device auto`
|
| 117 |
+
|
| 118 |
+
#### GNN (CLIP/DINO + bipartite native GNN)
|
| 119 |
+
|
| 120 |
+
- GNN smoke run:
|
| 121 |
+
|
| 122 |
+
`python scripts/train_gnn.py --run-name smoke_gnn --mode smoke --device auto`
|
| 123 |
+
|
| 124 |
+
- Compare baseline vs GNN (after both smoke runs complete):
|
| 125 |
+
|
| 126 |
+
`python scripts/compare_baseline_vs_gnn.py --baseline-run smoke_baseline --gnn-run smoke_gnn`
|
| 127 |
|
| 128 |
## Expected Outputs
|
| 129 |
|
README_SPACE.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Hugging Face Space Setup
|
| 2 |
+
|
| 3 |
+
### 1. Prepare Repository
|
| 4 |
+
|
| 5 |
+
Push this project (including `app.py`, `requirements-space.txt`, and checkpoints) to a Git repository that you will connect to your Space.
|
| 6 |
+
|
| 7 |
+
Recommended checkpoint locations:
|
| 8 |
+
|
| 9 |
+
- Baseline: `outputs/smoke_baseline/best.pt`
|
| 10 |
+
- GNN: `outputs/smoke_gnn/best.pt`
|
| 11 |
+
|
| 12 |
+
### 2. Create a New Space
|
| 13 |
+
|
| 14 |
+
1. On Hugging Face, create a new **Space**.
|
| 15 |
+
2. Select **Gradio** as the SDK.
|
| 16 |
+
3. Point the Space to this repository (or upload the files manually).
|
| 17 |
+
|
| 18 |
+
### 3. Configure Runtime
|
| 19 |
+
|
| 20 |
+
- Hardware: start with **CPU**; upgrade to GPU if needed.
|
| 21 |
+
- Python dependencies:
|
| 22 |
+
- Set the Space to use `requirements-space.txt` as the dependency file.
|
| 23 |
+
|
| 24 |
+
### 4. Local Test Before Pushing
|
| 25 |
+
|
| 26 |
+
Create and activate a clean virtualenv, then run:
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
pip install -r requirements-space.txt
|
| 30 |
+
python app.py
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Open the printed URL in your browser, upload a chair image, and verify that:
|
| 34 |
+
|
| 35 |
+
- Baseline and GNN models both run.
|
| 36 |
+
- Switching model type updates which checkpoint is used.
|
| 37 |
+
|
| 38 |
+
### 5. Expected Behavior in the Space
|
| 39 |
+
|
| 40 |
+
- The app starts from `app.py`.
|
| 41 |
+
- Default checkpoint paths:
|
| 42 |
+
- Baseline: `outputs/smoke_baseline/best.pt`
|
| 43 |
+
- GNN: `outputs/smoke_gnn/best.pt`
|
| 44 |
+
- You can override checkpoint paths via the text box in the UI.
|
| 45 |
+
|
app.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import torch
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
from infer.pipeline import LoadedModel, load_checkpoint, predict_image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_BASELINE_CKPT = Path("outputs/smoke_baseline/best.pt")
|
| 14 |
+
DEFAULT_GNN_CKPT = Path("outputs/smoke_gnn/best.pt")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ModelRegistry:
|
| 18 |
+
def __init__(self) -> None:
|
| 19 |
+
self._cache: Dict[str, LoadedModel] = {}
|
| 20 |
+
|
| 21 |
+
def get(self, key: str, path: Path, device: torch.device) -> LoadedModel:
|
| 22 |
+
cached = self._cache.get(key)
|
| 23 |
+
if cached is not None:
|
| 24 |
+
return cached
|
| 25 |
+
loaded = load_checkpoint(path, device=device)
|
| 26 |
+
self._cache[key] = loaded
|
| 27 |
+
return loaded
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
registry = ModelRegistry()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def infer_gradio(
|
| 34 |
+
image: Image.Image,
|
| 35 |
+
model_type: str,
|
| 36 |
+
checkpoint_path: str,
|
| 37 |
+
top_k: int,
|
| 38 |
+
threshold: float,
|
| 39 |
+
) -> Any:
|
| 40 |
+
if image is None:
|
| 41 |
+
return [], "No image provided."
|
| 42 |
+
path = Path(checkpoint_path).expanduser()
|
| 43 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 44 |
+
try:
|
| 45 |
+
key = f"{model_type}:{path}"
|
| 46 |
+
loaded = registry.get(key, path, device)
|
| 47 |
+
# Save temp image to disk-agnostic path is unnecessary; use pipeline logic directly.
|
| 48 |
+
from infer.pipeline import predict_image as predict_from_loaded
|
| 49 |
+
from infer.pipeline import LoadedModel as _LM # type: ignore
|
| 50 |
+
|
| 51 |
+
assert isinstance(loaded, _LM)
|
| 52 |
+
# Wrap PIL image call using temporary in-memory path emulation.
|
| 53 |
+
# For simplicity, reuse adapter directly.
|
| 54 |
+
result = loaded.adapter.predict(
|
| 55 |
+
image=image,
|
| 56 |
+
label_vocab=loaded.label_vocab,
|
| 57 |
+
top_k=top_k,
|
| 58 |
+
threshold=threshold,
|
| 59 |
+
)
|
| 60 |
+
rows = [
|
| 61 |
+
{"label": label, "score": round(score, 4), "positive@thr": label in result.positives}
|
| 62 |
+
for label, score in zip(result.labels, result.scores)
|
| 63 |
+
]
|
| 64 |
+
message = f"Positives (>= {threshold:.2f}): {', '.join(result.positives) if result.positives else 'none'}"
|
| 65 |
+
return rows, message
|
| 66 |
+
except Exception as exc: # noqa: BLE001
|
| 67 |
+
return [], f"Error: {exc}"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
with gr.Blocks() as demo:
|
| 71 |
+
gr.Markdown("# Adjective-Aware Chair Attributes\nSelect baseline or GNN, upload an image, and view attribute scores.")
|
| 72 |
+
|
| 73 |
+
with gr.Row():
|
| 74 |
+
with gr.Column(scale=1):
|
| 75 |
+
image_input = gr.Image(type="pil", label="Chair image")
|
| 76 |
+
model_type = gr.Radio(
|
| 77 |
+
choices=["baseline", "gnn"],
|
| 78 |
+
value="baseline",
|
| 79 |
+
label="Model type",
|
| 80 |
+
)
|
| 81 |
+
checkpoint = gr.Textbox(
|
| 82 |
+
value=str(DEFAULT_BASELINE_CKPT),
|
| 83 |
+
label="Checkpoint path",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
def _sync_ckpt(choice: str) -> str:
|
| 87 |
+
return str(DEFAULT_GNN_CKPT if choice == "gnn" else DEFAULT_BASELINE_CKPT)
|
| 88 |
+
|
| 89 |
+
model_type.change(_sync_ckpt, inputs=model_type, outputs=checkpoint)
|
| 90 |
+
|
| 91 |
+
top_k = gr.Slider(1, 20, value=5, step=1, label="Top-K")
|
| 92 |
+
threshold = gr.Slider(0.0, 1.0, value=0.5, step=0.01, label="Threshold")
|
| 93 |
+
run_btn = gr.Button("Run inference")
|
| 94 |
+
|
| 95 |
+
with gr.Column(scale=2):
|
| 96 |
+
table = gr.Dataframe(
|
| 97 |
+
headers=["label", "score", "positive@thr"],
|
| 98 |
+
datatype=["str", "number", "bool"],
|
| 99 |
+
label="Attribute scores",
|
| 100 |
+
)
|
| 101 |
+
message = gr.Markdown()
|
| 102 |
+
|
| 103 |
+
run_btn.click(
|
| 104 |
+
infer_gradio,
|
| 105 |
+
inputs=[image_input, model_type, checkpoint, top_k, threshold],
|
| 106 |
+
outputs=[table, message],
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
demo.launch()
|
| 112 |
+
|
requirements-space.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
|
| 3 |
+
torch
|
| 4 |
+
torchvision
|
| 5 |
+
torchaudio
|
| 6 |
+
timm
|
| 7 |
+
Pillow
|
| 8 |
+
gradio
|
| 9 |
+
|
requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ timm
|
|
| 10 |
scikit-learn
|
| 11 |
Pillow
|
| 12 |
tqdm
|
|
|
|
|
|
| 10 |
scikit-learn
|
| 11 |
Pillow
|
| 12 |
tqdm
|
| 13 |
+
numpy
|
scripts/compare_baseline_vs_gnn.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_metrics(run_name: str, output_dir: Path) -> Dict[str, Any]:
|
| 13 |
+
metrics_path = output_dir / run_name / "metrics.json"
|
| 14 |
+
if not metrics_path.exists():
|
| 15 |
+
raise RuntimeError(f"Metrics not found: {metrics_path}")
|
| 16 |
+
with metrics_path.open("r", encoding="utf-8") as handle:
|
| 17 |
+
return json.load(handle)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_args() -> argparse.Namespace:
|
| 21 |
+
parser = argparse.ArgumentParser(description="Compare baseline vs GNN runs on test metrics.")
|
| 22 |
+
parser.add_argument("--baseline-run", type=str, required=True, help="Baseline run_name (e.g. smoke_baseline).")
|
| 23 |
+
parser.add_argument("--gnn-run", type=str, required=True, help="GNN run_name (e.g. smoke_gnn).")
|
| 24 |
+
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "outputs")
|
| 25 |
+
return parser.parse_args()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main() -> None:
|
| 29 |
+
args = parse_args()
|
| 30 |
+
baseline = load_metrics(args.baseline_run, args.output_dir)
|
| 31 |
+
gnn = load_metrics(args.gnn_run, args.output_dir)
|
| 32 |
+
|
| 33 |
+
def extract(row: Dict[str, Any]) -> Dict[str, float]:
|
| 34 |
+
test = row.get("test_metrics", {})
|
| 35 |
+
return {
|
| 36 |
+
"map": float(test.get("map", 0.0)),
|
| 37 |
+
"macro_f1": float(test.get("macro_f1", 0.0)),
|
| 38 |
+
"micro_f1": float(test.get("micro_f1", 0.0)),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
b = extract(baseline)
|
| 42 |
+
g = extract(gnn)
|
| 43 |
+
delta = {k: g[k] - b[k] for k in b.keys()}
|
| 44 |
+
|
| 45 |
+
report = {
|
| 46 |
+
"baseline_run": args.baseline_run,
|
| 47 |
+
"gnn_run": args.gnn_run,
|
| 48 |
+
"baseline_test": b,
|
| 49 |
+
"gnn_test": g,
|
| 50 |
+
"delta": delta,
|
| 51 |
+
}
|
| 52 |
+
print(json.dumps(report, indent=2))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
| 57 |
+
|
scripts/infer.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from infer.pipeline import load_checkpoint, predict_image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_args() -> argparse.Namespace:
|
| 14 |
+
parser = argparse.ArgumentParser(description="Run single-image inference for baseline or GNN checkpoints.")
|
| 15 |
+
parser.add_argument("--model-type", type=str, choices=["auto", "baseline", "gnn"], default="auto")
|
| 16 |
+
parser.add_argument(
|
| 17 |
+
"--checkpoint",
|
| 18 |
+
type=Path,
|
| 19 |
+
default=Path("outputs/smoke_baseline/best.pt"),
|
| 20 |
+
help="Path to checkpoint (.pt).",
|
| 21 |
+
)
|
| 22 |
+
parser.add_argument(
|
| 23 |
+
"--image",
|
| 24 |
+
type=Path,
|
| 25 |
+
required=True,
|
| 26 |
+
help="Path to input image.",
|
| 27 |
+
)
|
| 28 |
+
parser.add_argument("--top-k", type=int, default=5)
|
| 29 |
+
parser.add_argument("--threshold", type=float, default=0.5)
|
| 30 |
+
parser.add_argument("--device", type=str, default="auto", choices=["auto", "cuda", "cpu"])
|
| 31 |
+
parser.add_argument("--benchmark", action="store_true", help="Print simple latency timing.")
|
| 32 |
+
return parser.parse_args()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main() -> None:
|
| 36 |
+
args = parse_args()
|
| 37 |
+
|
| 38 |
+
if args.device == "auto":
|
| 39 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 40 |
+
else:
|
| 41 |
+
device = torch.device(args.device)
|
| 42 |
+
|
| 43 |
+
loaded = load_checkpoint(args.checkpoint, device=device)
|
| 44 |
+
|
| 45 |
+
if args.benchmark:
|
| 46 |
+
import time
|
| 47 |
+
|
| 48 |
+
start = time.time()
|
| 49 |
+
result = predict_image(
|
| 50 |
+
image_path=args.image,
|
| 51 |
+
loaded=loaded,
|
| 52 |
+
top_k=args.top_k,
|
| 53 |
+
threshold=args.threshold,
|
| 54 |
+
)
|
| 55 |
+
elapsed = time.time() - start
|
| 56 |
+
else:
|
| 57 |
+
result = predict_image(
|
| 58 |
+
image_path=args.image,
|
| 59 |
+
loaded=loaded,
|
| 60 |
+
top_k=args.top_k,
|
| 61 |
+
threshold=args.threshold,
|
| 62 |
+
)
|
| 63 |
+
elapsed = None
|
| 64 |
+
|
| 65 |
+
payload: Dict[str, Any] = {
|
| 66 |
+
"image": str(args.image),
|
| 67 |
+
"top_k": args.top_k,
|
| 68 |
+
"threshold": args.threshold,
|
| 69 |
+
"labels": result.labels,
|
| 70 |
+
"scores": result.scores,
|
| 71 |
+
"positives": result.positives,
|
| 72 |
+
}
|
| 73 |
+
if elapsed is not None:
|
| 74 |
+
payload["latency_sec"] = elapsed
|
| 75 |
+
|
| 76 |
+
print(json.dumps(payload, indent=2))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
| 81 |
+
|
scripts/train_baseline.py
CHANGED
|
@@ -96,7 +96,7 @@ def build_loaders(cfg: Dict[str, Any], batch_size: int, eval_batch_size: int, sm
|
|
| 96 |
|
| 97 |
num_workers = int(cfg["dataset"].get("num_workers", 0))
|
| 98 |
if smoke:
|
| 99 |
-
num_workers = min(num_workers,
|
| 100 |
|
| 101 |
train_loader = build_dataloader(
|
| 102 |
dataset=train_ds,
|
|
@@ -189,8 +189,8 @@ def train() -> None:
|
|
| 189 |
|
| 190 |
smoke = args.mode == "smoke"
|
| 191 |
train_epochs = 2 if smoke else int(cfg["training"]["stage1"].get("epochs", 20))
|
| 192 |
-
batch_size =
|
| 193 |
-
eval_batch_size =
|
| 194 |
|
| 195 |
cuda_available = torch.cuda.is_available()
|
| 196 |
selected_device = "cuda" if (args.device == "auto" and cuda_available) else args.device
|
|
|
|
| 96 |
|
| 97 |
num_workers = int(cfg["dataset"].get("num_workers", 0))
|
| 98 |
if smoke:
|
| 99 |
+
num_workers = min(num_workers, 8)
|
| 100 |
|
| 101 |
train_loader = build_dataloader(
|
| 102 |
dataset=train_ds,
|
|
|
|
| 189 |
|
| 190 |
smoke = args.mode == "smoke"
|
| 191 |
train_epochs = 2 if smoke else int(cfg["training"]["stage1"].get("epochs", 20))
|
| 192 |
+
batch_size = 4 if smoke else int(cfg["batching"].get("batch_size", 32))
|
| 193 |
+
eval_batch_size = 4 if smoke else int(cfg["batching"].get("eval_batch_size", 64))
|
| 194 |
|
| 195 |
cuda_available = torch.cuda.is_available()
|
| 196 |
selected_device = "cuda" if (args.device == "auto" and cuda_available) else args.device
|
scripts/train_gnn.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, List, Tuple
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from tqdm import tqdm
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 18 |
+
if str(SRC_DIR) not in sys.path:
|
| 19 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 20 |
+
|
| 21 |
+
from data.io_utils import write_json # noqa: E402
|
| 22 |
+
from train.config_utils import ( # noqa: E402
|
| 23 |
+
apply_dot_overrides,
|
| 24 |
+
deep_merge,
|
| 25 |
+
load_yaml,
|
| 26 |
+
parse_overrides_json,
|
| 27 |
+
)
|
| 28 |
+
from train.dataset import ( # noqa: E402
|
| 29 |
+
ChairAttributeDataset,
|
| 30 |
+
build_dataloader,
|
| 31 |
+
class_pos_weights,
|
| 32 |
+
load_json,
|
| 33 |
+
split_paths,
|
| 34 |
+
)
|
| 35 |
+
from train.encoders import build_backbones # noqa: E402
|
| 36 |
+
from train.graph_builder import build_bipartite_batch # noqa: E402
|
| 37 |
+
from train.graph_models import NativeGNNClassifier # noqa: E402
|
| 38 |
+
from train.losses import bce_logits_loss # noqa: E402
|
| 39 |
+
from train.metrics import compute_metrics # noqa: E402
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def log(message: str) -> None:
|
| 43 |
+
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
| 44 |
+
print(f"[{ts}] [train_gnn] {message}", flush=True)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def parse_args() -> argparse.Namespace:
|
| 48 |
+
parser = argparse.ArgumentParser(description="Train GNN model (bipartite graph).")
|
| 49 |
+
parser.add_argument("--dataset-config", type=Path, default=REPO_ROOT / "configs" / "dataset.yaml")
|
| 50 |
+
parser.add_argument("--model-config", type=Path, default=REPO_ROOT / "configs" / "model.yaml")
|
| 51 |
+
parser.add_argument("--train-config", type=Path, default=REPO_ROOT / "configs" / "train.yaml")
|
| 52 |
+
parser.add_argument("--eval-config", type=Path, default=REPO_ROOT / "configs" / "eval.yaml")
|
| 53 |
+
parser.add_argument("--run-name", type=str, required=True)
|
| 54 |
+
parser.add_argument("--overrides-json", type=str, default="")
|
| 55 |
+
parser.add_argument("--mode", type=str, default="full", choices=["smoke", "full"])
|
| 56 |
+
parser.add_argument("--device", type=str, default="auto", choices=["auto", "cuda", "cpu"])
|
| 57 |
+
return parser.parse_args()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def set_seed(seed: int, deterministic: bool) -> None:
|
| 61 |
+
random.seed(seed)
|
| 62 |
+
np.random.seed(seed)
|
| 63 |
+
torch.manual_seed(seed)
|
| 64 |
+
if torch.cuda.is_available():
|
| 65 |
+
torch.cuda.manual_seed_all(seed)
|
| 66 |
+
torch.backends.cudnn.deterministic = deterministic
|
| 67 |
+
torch.backends.cudnn.benchmark = not deterministic
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load_combined_config(args: argparse.Namespace) -> Dict[str, Any]:
|
| 71 |
+
dataset_cfg = load_yaml(args.dataset_config)
|
| 72 |
+
model_cfg = load_yaml(args.model_config)
|
| 73 |
+
train_cfg = load_yaml(args.train_config)
|
| 74 |
+
eval_cfg = load_yaml(args.eval_config)
|
| 75 |
+
combined = deep_merge(dataset_cfg, model_cfg)
|
| 76 |
+
combined = deep_merge(combined, train_cfg)
|
| 77 |
+
combined = deep_merge(combined, eval_cfg)
|
| 78 |
+
overrides = parse_overrides_json(args.overrides_json)
|
| 79 |
+
if overrides:
|
| 80 |
+
combined = apply_dot_overrides(combined, overrides)
|
| 81 |
+
return combined
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_loaders(cfg: Dict[str, Any], batch_size: int, eval_batch_size: int, smoke: bool) -> Tuple[Any, Any, Any, Dict[str, int]]:
|
| 85 |
+
processed_root = REPO_ROOT / cfg["dataset"]["processed_dir"]
|
| 86 |
+
label_vocab_path = processed_root / cfg["processing"]["label_vocab_file"]
|
| 87 |
+
label_freq_path = processed_root / cfg["processing"]["label_frequency_file"]
|
| 88 |
+
cache_dir = REPO_ROOT / cfg["dataset"]["root_dir"] / "images"
|
| 89 |
+
splits = split_paths(processed_root=processed_root, split_dir_name=cfg["processing"]["split_dir"])
|
| 90 |
+
|
| 91 |
+
label_vocab = load_json(label_vocab_path)
|
| 92 |
+
label_freq = load_json(label_freq_path)
|
| 93 |
+
|
| 94 |
+
train_ds = ChairAttributeDataset(splits["train"], label_vocab_path, REPO_ROOT, cache_dir)
|
| 95 |
+
val_ds = ChairAttributeDataset(splits["val"], label_vocab_path, REPO_ROOT, cache_dir)
|
| 96 |
+
test_ds = ChairAttributeDataset(splits["test"], label_vocab_path, REPO_ROOT, cache_dir)
|
| 97 |
+
|
| 98 |
+
if smoke:
|
| 99 |
+
# Cap samples for faster smoke training.
|
| 100 |
+
train_ds.samples = train_ds.samples[:512]
|
| 101 |
+
val_ds.samples = val_ds.samples[:128]
|
| 102 |
+
test_ds.samples = test_ds.samples[:128]
|
| 103 |
+
|
| 104 |
+
num_workers = int(cfg["dataset"].get("num_workers", 0))
|
| 105 |
+
if smoke:
|
| 106 |
+
num_workers = min(num_workers, 8)
|
| 107 |
+
|
| 108 |
+
train_loader = build_dataloader(
|
| 109 |
+
dataset=train_ds,
|
| 110 |
+
batch_size=batch_size,
|
| 111 |
+
shuffle=True,
|
| 112 |
+
num_workers=num_workers,
|
| 113 |
+
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
|
| 114 |
+
)
|
| 115 |
+
val_loader = build_dataloader(
|
| 116 |
+
dataset=val_ds,
|
| 117 |
+
batch_size=eval_batch_size,
|
| 118 |
+
shuffle=False,
|
| 119 |
+
num_workers=num_workers,
|
| 120 |
+
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
|
| 121 |
+
)
|
| 122 |
+
test_loader = build_dataloader(
|
| 123 |
+
dataset=test_ds,
|
| 124 |
+
batch_size=eval_batch_size,
|
| 125 |
+
shuffle=False,
|
| 126 |
+
num_workers=num_workers,
|
| 127 |
+
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
|
| 128 |
+
)
|
| 129 |
+
return train_loader, val_loader, test_loader, {"vocab": label_vocab, "freq": label_freq}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def build_backbone_and_gnn(
|
| 133 |
+
cfg: Dict[str, Any],
|
| 134 |
+
num_labels: int,
|
| 135 |
+
device: torch.device,
|
| 136 |
+
) -> Tuple[torch.nn.Module, NativeGNNClassifier, int]:
|
| 137 |
+
use_clip = bool(cfg["encoders"]["clip"]["enabled"])
|
| 138 |
+
use_dino = bool(cfg["encoders"]["dino"]["enabled"])
|
| 139 |
+
clip_name = str(cfg["encoders"]["clip"].get("model_name", "vit_base_patch16_clip_224.openai"))
|
| 140 |
+
dino_name = str(cfg["encoders"]["dino"].get("model_name", "vit_base_patch14_dinov2.lvd142m"))
|
| 141 |
+
if clip_name == "ViT-B-32":
|
| 142 |
+
clip_name = "vit_base_patch16_clip_224.openai"
|
| 143 |
+
if dino_name == "vit_base_patch14_dinov2":
|
| 144 |
+
dino_name = "vit_base_patch14_dinov2.lvd142m"
|
| 145 |
+
|
| 146 |
+
clip_backbone, dino_backbone, feature_dim = build_backbones(
|
| 147 |
+
use_clip=use_clip,
|
| 148 |
+
use_dino=use_dino,
|
| 149 |
+
clip_model_name=clip_name,
|
| 150 |
+
dino_model_name=dino_name,
|
| 151 |
+
clip_pretrained=bool(cfg["encoders"]["clip"].get("pretrained", True)),
|
| 152 |
+
dino_pretrained=bool(cfg["encoders"]["dino"].get("pretrained", True)),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
class BackboneWrapper(torch.nn.Module):
|
| 156 |
+
def __init__(self, clip_module: torch.nn.Module | None, dino_module: torch.nn.Module | None) -> None:
|
| 157 |
+
super().__init__()
|
| 158 |
+
self.clip_module = clip_module
|
| 159 |
+
self.dino_module = dino_module
|
| 160 |
+
|
| 161 |
+
def encode(self, images: List[Any], device: torch.device) -> torch.Tensor:
|
| 162 |
+
feats: List[torch.Tensor] = []
|
| 163 |
+
if self.clip_module is not None:
|
| 164 |
+
feats.append(self.clip_module.forward_pil(images, device=device))
|
| 165 |
+
if self.dino_module is not None:
|
| 166 |
+
feats.append(self.dino_module.forward_pil(images, device=device))
|
| 167 |
+
if len(feats) == 1:
|
| 168 |
+
return feats[0]
|
| 169 |
+
return torch.cat(feats, dim=1)
|
| 170 |
+
|
| 171 |
+
backbone = BackboneWrapper(clip_backbone, dino_backbone).to(device)
|
| 172 |
+
|
| 173 |
+
hidden_dims = [layer_cfg["out_dim"] for layer_cfg in cfg["gnn"]["layers"]]
|
| 174 |
+
dropout = float(cfg["gnn"].get("dropout", 0.2))
|
| 175 |
+
gnn_model = NativeGNNClassifier(
|
| 176 |
+
in_dim=feature_dim,
|
| 177 |
+
hidden_dims=hidden_dims,
|
| 178 |
+
num_attributes=num_labels,
|
| 179 |
+
dropout=dropout,
|
| 180 |
+
).to(device)
|
| 181 |
+
|
| 182 |
+
return backbone, gnn_model, feature_dim
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def run_eval(
|
| 186 |
+
backbone: torch.nn.Module,
|
| 187 |
+
gnn_model: NativeGNNClassifier,
|
| 188 |
+
loader: Any,
|
| 189 |
+
device: torch.device,
|
| 190 |
+
pos_weight: torch.Tensor,
|
| 191 |
+
) -> Dict[str, Any]:
|
| 192 |
+
backbone.eval()
|
| 193 |
+
gnn_model.eval()
|
| 194 |
+
all_logits: List[np.ndarray] = []
|
| 195 |
+
all_targets: List[np.ndarray] = []
|
| 196 |
+
losses: List[float] = []
|
| 197 |
+
with torch.no_grad():
|
| 198 |
+
for batch in loader:
|
| 199 |
+
targets = batch["targets"].to(device)
|
| 200 |
+
images = batch["images"]
|
| 201 |
+
feats = backbone.encode(images=images, device=device).unsqueeze(1)
|
| 202 |
+
graph = build_bipartite_batch(feats=feats, targets=targets)
|
| 203 |
+
logits = gnn_model(graph)
|
| 204 |
+
loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
|
| 205 |
+
losses.append(float(loss.item()))
|
| 206 |
+
all_logits.append(logits.detach().cpu().numpy())
|
| 207 |
+
all_targets.append(targets.detach().cpu().numpy())
|
| 208 |
+
logits_np = np.concatenate(all_logits, axis=0) if all_logits else np.zeros((0, 1))
|
| 209 |
+
targets_np = np.concatenate(all_targets, axis=0) if all_targets else np.zeros((0, 1))
|
| 210 |
+
metrics = compute_metrics(targets=targets_np, logits=logits_np)
|
| 211 |
+
metrics["loss"] = float(np.mean(losses)) if losses else 0.0
|
| 212 |
+
return metrics
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def train() -> None:
|
| 216 |
+
args = parse_args()
|
| 217 |
+
log(f"Starting run_name={args.run_name} mode={args.mode} device_request={args.device}")
|
| 218 |
+
log(f"dataset_config={args.dataset_config}")
|
| 219 |
+
log(f"model_config={args.model_config}")
|
| 220 |
+
log(f"train_config={args.train_config}")
|
| 221 |
+
cfg = load_combined_config(args)
|
| 222 |
+
run_cfg = cfg.get("run", {})
|
| 223 |
+
seed = int(run_cfg.get("seed", 42))
|
| 224 |
+
deterministic = bool(run_cfg.get("deterministic", True))
|
| 225 |
+
set_seed(seed=seed, deterministic=deterministic)
|
| 226 |
+
log(f"Seed set to {seed}, deterministic={deterministic}")
|
| 227 |
+
|
| 228 |
+
smoke = args.mode == "smoke"
|
| 229 |
+
train_epochs = 1 if smoke else int(cfg["training"]["stage1"].get("epochs", 20))
|
| 230 |
+
batch_size = 4 if smoke else int(cfg["batching"].get("batch_size", 32))
|
| 231 |
+
eval_batch_size = 4 if smoke else int(cfg["batching"].get("eval_batch_size", 64))
|
| 232 |
+
|
| 233 |
+
cuda_available = torch.cuda.is_available()
|
| 234 |
+
selected_device = "cuda" if (args.device == "auto" and cuda_available) else args.device
|
| 235 |
+
if args.device == "auto" and not cuda_available:
|
| 236 |
+
selected_device = "cpu"
|
| 237 |
+
if selected_device == "cuda" and not cuda_available:
|
| 238 |
+
raise RuntimeError(
|
| 239 |
+
"CUDA requested but not available in torch build/environment. "
|
| 240 |
+
"Install CUDA-enabled PyTorch in the active virtualenv."
|
| 241 |
+
)
|
| 242 |
+
device = torch.device(selected_device)
|
| 243 |
+
log(
|
| 244 |
+
f"Resolved device={device.type}, torch_cuda_available={cuda_available}, "
|
| 245 |
+
f"torch_version={torch.__version__}, torch_cuda={torch.version.cuda}"
|
| 246 |
+
)
|
| 247 |
+
log("Building dataloaders from processed manifests...")
|
| 248 |
+
train_loader, val_loader, test_loader, label_info = build_loaders(
|
| 249 |
+
cfg=cfg,
|
| 250 |
+
batch_size=batch_size,
|
| 251 |
+
eval_batch_size=eval_batch_size,
|
| 252 |
+
smoke=smoke,
|
| 253 |
+
)
|
| 254 |
+
log(
|
| 255 |
+
f"Dataloader sizes -> train_batches={len(train_loader)} val_batches={len(val_loader)} "
|
| 256 |
+
f"test_batches={len(test_loader)}"
|
| 257 |
+
)
|
| 258 |
+
label_vocab = label_info["vocab"]
|
| 259 |
+
label_freq = label_info["freq"]
|
| 260 |
+
num_labels = len(label_vocab)
|
| 261 |
+
log(f"Label space size: {num_labels}")
|
| 262 |
+
log("Building encoder backbones and GNN head (first run may download pretrained weights)...")
|
| 263 |
+
backbone, gnn_model, _ = build_backbone_and_gnn(cfg=cfg, num_labels=num_labels, device=device)
|
| 264 |
+
log("Model construction complete")
|
| 265 |
+
pos_weight = class_pos_weights(label_frequencies=label_freq, label_vocab=label_vocab).to(device)
|
| 266 |
+
|
| 267 |
+
lr = float(cfg["training"]["stage1"].get("lr_head", 1e-3))
|
| 268 |
+
weight_decay = float(cfg["optimization"].get("weight_decay", 1e-4))
|
| 269 |
+
optimizer = torch.optim.AdamW(
|
| 270 |
+
list(gnn_model.parameters()),
|
| 271 |
+
lr=lr,
|
| 272 |
+
weight_decay=weight_decay,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
output_root = REPO_ROOT / run_cfg.get("output_dir", "outputs")
|
| 276 |
+
run_dir = output_root / args.run_name
|
| 277 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 278 |
+
|
| 279 |
+
history: List[Dict[str, Any]] = []
|
| 280 |
+
best_map = -1.0
|
| 281 |
+
best_path = run_dir / "best.pt"
|
| 282 |
+
log(f"Training for epochs={train_epochs}, batch_size={batch_size}, eval_batch_size={eval_batch_size}")
|
| 283 |
+
for epoch in range(1, train_epochs + 1):
|
| 284 |
+
backbone.eval()
|
| 285 |
+
gnn_model.train()
|
| 286 |
+
train_losses: List[float] = []
|
| 287 |
+
for batch in tqdm(train_loader, desc=f"{args.run_name} epoch {epoch}/{train_epochs}"):
|
| 288 |
+
targets = batch["targets"].to(device)
|
| 289 |
+
images = batch["images"]
|
| 290 |
+
feats = backbone.encode(images=images, device=device).unsqueeze(1)
|
| 291 |
+
graph = build_bipartite_batch(feats=feats, targets=targets)
|
| 292 |
+
logits = gnn_model(graph)
|
| 293 |
+
loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
|
| 294 |
+
optimizer.zero_grad(set_to_none=True)
|
| 295 |
+
loss.backward()
|
| 296 |
+
optimizer.step()
|
| 297 |
+
train_losses.append(float(loss.item()))
|
| 298 |
+
|
| 299 |
+
val_metrics = run_eval(
|
| 300 |
+
backbone=backbone,
|
| 301 |
+
gnn_model=gnn_model,
|
| 302 |
+
loader=val_loader,
|
| 303 |
+
device=device,
|
| 304 |
+
pos_weight=pos_weight,
|
| 305 |
+
)
|
| 306 |
+
epoch_row = {
|
| 307 |
+
"epoch": epoch,
|
| 308 |
+
"train_loss": float(np.mean(train_losses)) if train_losses else 0.0,
|
| 309 |
+
"val_loss": val_metrics["loss"],
|
| 310 |
+
"val_map": val_metrics["map"],
|
| 311 |
+
"val_macro_f1": val_metrics["macro_f1"],
|
| 312 |
+
"val_micro_f1": val_metrics["micro_f1"],
|
| 313 |
+
}
|
| 314 |
+
history.append(epoch_row)
|
| 315 |
+
log(
|
| 316 |
+
f"Epoch {epoch}/{train_epochs} -> train_loss={epoch_row['train_loss']:.4f} "
|
| 317 |
+
f"val_map={epoch_row['val_map']:.4f} val_macro_f1={epoch_row['val_macro_f1']:.4f}"
|
| 318 |
+
)
|
| 319 |
+
if val_metrics["map"] > best_map:
|
| 320 |
+
best_map = val_metrics["map"]
|
| 321 |
+
torch.save(
|
| 322 |
+
{
|
| 323 |
+
"backbone_state": backbone.state_dict(),
|
| 324 |
+
"gnn_state": gnn_model.state_dict(),
|
| 325 |
+
"label_vocab": label_vocab,
|
| 326 |
+
"config": cfg,
|
| 327 |
+
},
|
| 328 |
+
best_path,
|
| 329 |
+
)
|
| 330 |
+
log(f"Saved new best checkpoint at epoch {epoch}: {best_path}")
|
| 331 |
+
|
| 332 |
+
log("Running final test evaluation using best checkpoint...")
|
| 333 |
+
checkpoint = torch.load(best_path, map_location=device)
|
| 334 |
+
backbone.load_state_dict(checkpoint["backbone_state"])
|
| 335 |
+
gnn_model.load_state_dict(checkpoint["gnn_state"])
|
| 336 |
+
test_metrics = run_eval(
|
| 337 |
+
backbone=backbone,
|
| 338 |
+
gnn_model=gnn_model,
|
| 339 |
+
loader=test_loader,
|
| 340 |
+
device=device,
|
| 341 |
+
pos_weight=pos_weight,
|
| 342 |
+
)
|
| 343 |
+
payload = {
|
| 344 |
+
"run_name": args.run_name,
|
| 345 |
+
"mode": args.mode,
|
| 346 |
+
"num_labels": num_labels,
|
| 347 |
+
"best_val_map": best_map,
|
| 348 |
+
"history": history,
|
| 349 |
+
"test_metrics": test_metrics,
|
| 350 |
+
}
|
| 351 |
+
write_json(run_dir / "metrics.json", payload)
|
| 352 |
+
write_json(run_dir / "label_vocab.json", label_vocab)
|
| 353 |
+
with (run_dir / "overrides.json").open("w", encoding="utf-8") as handle:
|
| 354 |
+
json.dump(parse_overrides_json(args.overrides_json), handle, indent=2, ensure_ascii=True)
|
| 355 |
+
log(f"Training complete for {args.run_name}")
|
| 356 |
+
print(json.dumps({"run_name": args.run_name, "test_map": test_metrics["map"]}, indent=2), flush=True)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
if __name__ == "__main__":
|
| 360 |
+
train()
|
| 361 |
+
|
src/infer/model_adapters.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, List, Tuple
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
from train.encoders import build_backbones
|
| 10 |
+
from train.graph_builder import build_bipartite_batch
|
| 11 |
+
from train.graph_models import NativeGNNClassifier
|
| 12 |
+
from train.models import BaselineClassifier
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class InferenceResult:
|
| 17 |
+
labels: List[str]
|
| 18 |
+
scores: List[float]
|
| 19 |
+
positives: List[str]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _sorted_labels(
|
| 23 |
+
scores: torch.Tensor,
|
| 24 |
+
label_vocab: Dict[str, int],
|
| 25 |
+
top_k: int,
|
| 26 |
+
threshold: float,
|
| 27 |
+
) -> InferenceResult:
|
| 28 |
+
inv_vocab = {idx: label for label, idx in label_vocab.items()}
|
| 29 |
+
probs = scores.sigmoid().detach().cpu().view(-1)
|
| 30 |
+
values, indices = torch.topk(probs, k=min(top_k, probs.numel()))
|
| 31 |
+
labels = [inv_vocab[int(i)] for i in indices]
|
| 32 |
+
positives = [label for label, score in zip(labels, values) if float(score) >= threshold]
|
| 33 |
+
return InferenceResult(
|
| 34 |
+
labels=labels,
|
| 35 |
+
scores=[float(v) for v in values],
|
| 36 |
+
positives=positives,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class BaseAdapter:
|
| 41 |
+
def __init__(self, device: torch.device) -> None:
|
| 42 |
+
self.device = device
|
| 43 |
+
|
| 44 |
+
def predict(
|
| 45 |
+
self,
|
| 46 |
+
image: Image.Image,
|
| 47 |
+
label_vocab: Dict[str, int],
|
| 48 |
+
top_k: int = 5,
|
| 49 |
+
threshold: float = 0.5,
|
| 50 |
+
) -> InferenceResult:
|
| 51 |
+
raise NotImplementedError
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class BaselineAdapter(BaseAdapter):
|
| 55 |
+
def __init__(self, checkpoint: Dict[str, Any], device: torch.device) -> None:
|
| 56 |
+
super().__init__(device)
|
| 57 |
+
cfg = checkpoint["config"]
|
| 58 |
+
num_labels = len(checkpoint["label_vocab"])
|
| 59 |
+
use_clip = bool(cfg["encoders"]["clip"]["enabled"])
|
| 60 |
+
use_dino = bool(cfg["encoders"]["dino"]["enabled"])
|
| 61 |
+
clip_name = str(cfg["encoders"]["clip"].get("model_name", "vit_base_patch16_clip_224.openai"))
|
| 62 |
+
dino_name = str(cfg["encoders"]["dino"].get("model_name", "vit_base_patch14_dinov2.lvd142m"))
|
| 63 |
+
if clip_name == "ViT-B-32":
|
| 64 |
+
clip_name = "vit_base_patch16_clip_224.openai"
|
| 65 |
+
if dino_name == "vit_base_patch14_dinov2":
|
| 66 |
+
dino_name = "vit_base_patch14_dinov2.lvd142m"
|
| 67 |
+
|
| 68 |
+
clip_backbone, dino_backbone, feature_dim = build_backbones(
|
| 69 |
+
use_clip=use_clip,
|
| 70 |
+
use_dino=use_dino,
|
| 71 |
+
clip_model_name=clip_name,
|
| 72 |
+
dino_model_name=dino_name,
|
| 73 |
+
clip_pretrained=bool(cfg["encoders"]["clip"].get("pretrained", True)),
|
| 74 |
+
dino_pretrained=bool(cfg["encoders"]["dino"].get("pretrained", True)),
|
| 75 |
+
)
|
| 76 |
+
hidden_dims = list(cfg["baseline_heads"]["mlp"].get("hidden_dims", [512, 256]))
|
| 77 |
+
dropout = float(cfg["baseline_heads"]["mlp"].get("dropout", 0.2))
|
| 78 |
+
model = BaselineClassifier(
|
| 79 |
+
clip_backbone=clip_backbone,
|
| 80 |
+
dino_backbone=dino_backbone,
|
| 81 |
+
feature_dim=feature_dim,
|
| 82 |
+
hidden_dims=hidden_dims,
|
| 83 |
+
dropout=dropout,
|
| 84 |
+
num_labels=num_labels,
|
| 85 |
+
)
|
| 86 |
+
model.load_state_dict(checkpoint["model_state"])
|
| 87 |
+
model.eval().to(device)
|
| 88 |
+
self.model = model
|
| 89 |
+
|
| 90 |
+
def predict(
|
| 91 |
+
self,
|
| 92 |
+
image: Image.Image,
|
| 93 |
+
label_vocab: Dict[str, int],
|
| 94 |
+
top_k: int = 5,
|
| 95 |
+
threshold: float = 0.5,
|
| 96 |
+
) -> InferenceResult:
|
| 97 |
+
with torch.no_grad():
|
| 98 |
+
logits = self.model([image], device=self.device)[0]
|
| 99 |
+
return _sorted_labels(logits, label_vocab, top_k, threshold)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class GNNAdapter(BaseAdapter):
|
| 103 |
+
def __init__(self, checkpoint: Dict[str, Any], device: torch.device) -> None:
|
| 104 |
+
super().__init__(device)
|
| 105 |
+
cfg = checkpoint["config"]
|
| 106 |
+
num_labels = len(checkpoint["label_vocab"])
|
| 107 |
+
|
| 108 |
+
use_clip = bool(cfg["encoders"]["clip"]["enabled"])
|
| 109 |
+
use_dino = bool(cfg["encoders"]["dino"]["enabled"])
|
| 110 |
+
clip_name = str(cfg["encoders"]["clip"].get("model_name", "vit_base_patch16_clip_224.openai"))
|
| 111 |
+
dino_name = str(cfg["encoders"]["dino"].get("model_name", "vit_base_patch14_dinov2.lvd142m"))
|
| 112 |
+
if clip_name == "ViT-B-32":
|
| 113 |
+
clip_name = "vit_base_patch16_clip_224.openai"
|
| 114 |
+
if dino_name == "vit_base_patch14_dinov2":
|
| 115 |
+
dino_name = "vit_base_patch14_dinov2.lvd142m"
|
| 116 |
+
|
| 117 |
+
clip_backbone, dino_backbone, feature_dim = build_backbones(
|
| 118 |
+
use_clip=use_clip,
|
| 119 |
+
use_dino=use_dino,
|
| 120 |
+
clip_model_name=clip_name,
|
| 121 |
+
dino_model_name=dino_name,
|
| 122 |
+
clip_pretrained=bool(cfg["encoders"]["clip"].get("pretrained", True)),
|
| 123 |
+
dino_pretrained=bool(cfg["encoders"]["dino"].get("pretrained", True)),
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
class BackboneWrapper(torch.nn.Module):
|
| 127 |
+
def __init__(self, clip_module: torch.nn.Module | None, dino_module: torch.nn.Module | None) -> None:
|
| 128 |
+
super().__init__()
|
| 129 |
+
self.clip_module = clip_module
|
| 130 |
+
self.dino_module = dino_module
|
| 131 |
+
|
| 132 |
+
def encode(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
|
| 133 |
+
feats: List[torch.Tensor] = []
|
| 134 |
+
if self.clip_module is not None:
|
| 135 |
+
feats.append(self.clip_module.forward_pil(images, device=device))
|
| 136 |
+
if self.dino_module is not None:
|
| 137 |
+
feats.append(self.dino_module.forward_pil(images, device=device))
|
| 138 |
+
if len(feats) == 1:
|
| 139 |
+
return feats[0]
|
| 140 |
+
return torch.cat(feats, dim=1)
|
| 141 |
+
|
| 142 |
+
backbone = BackboneWrapper(clip_backbone, dino_backbone).to(device)
|
| 143 |
+
self.backbone = backbone
|
| 144 |
+
|
| 145 |
+
hidden_dims = [layer_cfg["out_dim"] for layer_cfg in cfg["gnn"]["layers"]]
|
| 146 |
+
dropout = float(cfg["gnn"].get("dropout", 0.2))
|
| 147 |
+
gnn_model = NativeGNNClassifier(
|
| 148 |
+
in_dim=feature_dim,
|
| 149 |
+
hidden_dims=hidden_dims,
|
| 150 |
+
num_attributes=num_labels,
|
| 151 |
+
dropout=dropout,
|
| 152 |
+
)
|
| 153 |
+
gnn_model.load_state_dict(checkpoint["gnn_state"])
|
| 154 |
+
gnn_model.eval().to(device)
|
| 155 |
+
self.gnn_model = gnn_model
|
| 156 |
+
|
| 157 |
+
def predict(
|
| 158 |
+
self,
|
| 159 |
+
image: Image.Image,
|
| 160 |
+
label_vocab: Dict[str, int],
|
| 161 |
+
top_k: int = 5,
|
| 162 |
+
threshold: float = 0.5,
|
| 163 |
+
) -> InferenceResult:
|
| 164 |
+
with torch.no_grad():
|
| 165 |
+
feats = self.backbone.encode([image], device=self.device).unsqueeze(1)
|
| 166 |
+
# Single-sample multi-hot target is unknown at inference; use zeros to build graph shape.
|
| 167 |
+
dummy_targets = torch.zeros(1, len(label_vocab), device=self.device)
|
| 168 |
+
graph = build_bipartite_batch(feats=feats, targets=dummy_targets)
|
| 169 |
+
logits = self.gnn_model(graph)[0]
|
| 170 |
+
return _sorted_labels(logits, label_vocab, top_k, threshold)
|
| 171 |
+
|
src/infer/pipeline.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
from train.dataset import load_json
|
| 11 |
+
from .model_adapters import BaselineAdapter, GNNAdapter, InferenceResult
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class LoadedModel:
|
| 16 |
+
adapter: Any
|
| 17 |
+
label_vocab: Dict[str, int]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _detect_model_type(state: Dict[str, Any]) -> str:
|
| 21 |
+
if "model_state" in state:
|
| 22 |
+
return "baseline"
|
| 23 |
+
if "gnn_state" in state and "backbone_state" in state:
|
| 24 |
+
return "gnn"
|
| 25 |
+
raise RuntimeError("Unrecognized checkpoint format (expected baseline or GNN keys).")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_checkpoint(path: Path, device: torch.device | None = None) -> LoadedModel:
|
| 29 |
+
if device is None:
|
| 30 |
+
if torch.cuda.is_available():
|
| 31 |
+
device = torch.device("cuda")
|
| 32 |
+
else:
|
| 33 |
+
device = torch.device("cpu")
|
| 34 |
+
state: Dict[str, Any] = torch.load(path, map_location=device)
|
| 35 |
+
model_type = _detect_model_type(state)
|
| 36 |
+
label_vocab = state["label_vocab"]
|
| 37 |
+
if model_type == "baseline":
|
| 38 |
+
adapter = BaselineAdapter(state, device=device)
|
| 39 |
+
else:
|
| 40 |
+
adapter = GNNAdapter(state, device=device)
|
| 41 |
+
return LoadedModel(adapter=adapter, label_vocab=label_vocab)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def predict_image(
|
| 45 |
+
image_path: Path,
|
| 46 |
+
loaded: LoadedModel,
|
| 47 |
+
top_k: int = 5,
|
| 48 |
+
threshold: float = 0.5,
|
| 49 |
+
) -> InferenceResult:
|
| 50 |
+
image = Image.open(image_path).convert("RGB")
|
| 51 |
+
return loaded.adapter.predict(
|
| 52 |
+
image=image,
|
| 53 |
+
label_vocab=loaded.label_vocab,
|
| 54 |
+
top_k=top_k,
|
| 55 |
+
threshold=threshold,
|
| 56 |
+
)
|
| 57 |
+
|
src/train/graph_builder.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict, List, Tuple
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from .graph_models import BipartiteGraphBatch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_bipartite_batch(
|
| 11 |
+
feats: torch.Tensor,
|
| 12 |
+
targets: torch.Tensor,
|
| 13 |
+
) -> BipartiteGraphBatch:
|
| 14 |
+
"""Construct a simple bipartite batch graph from features and multi-hot targets.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
feats: [B, O, D] fused object features (one object per image for now).
|
| 18 |
+
targets: [B, A] multi-hot attribute labels.
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
BipartiteGraphBatch with:
|
| 22 |
+
- object_feats: feats
|
| 23 |
+
- attr_feats: trainable attribute embeddings will be attached later
|
| 24 |
+
- edge_index: edges from object indices to attribute indices
|
| 25 |
+
- edge_weight: all ones
|
| 26 |
+
"""
|
| 27 |
+
device = feats.device
|
| 28 |
+
bsz, num_objects, _ = feats.shape
|
| 29 |
+
num_attrs = targets.shape[1]
|
| 30 |
+
|
| 31 |
+
# For smoke training we assume one object per image (num_objects == 1).
|
| 32 |
+
# We still keep the general formulation for clarity.
|
| 33 |
+
object_indices: List[int] = []
|
| 34 |
+
attr_indices: List[int] = []
|
| 35 |
+
|
| 36 |
+
# Flatten batch/object into a single dimension of size B * O.
|
| 37 |
+
for b in range(bsz):
|
| 38 |
+
for o in range(num_objects):
|
| 39 |
+
flat_obj_idx = b * num_objects + o
|
| 40 |
+
active_attrs = (targets[b] > 0.5).nonzero(as_tuple=False).view(-1)
|
| 41 |
+
for a in active_attrs.tolist():
|
| 42 |
+
object_indices.append(flat_obj_idx)
|
| 43 |
+
attr_indices.append(b * num_attrs + a)
|
| 44 |
+
|
| 45 |
+
if not object_indices:
|
| 46 |
+
# No positive labels in batch; create a dummy self-loop to keep shapes valid.
|
| 47 |
+
object_indices = [0]
|
| 48 |
+
attr_indices = [0]
|
| 49 |
+
|
| 50 |
+
edge_index = torch.tensor(
|
| 51 |
+
[object_indices, attr_indices],
|
| 52 |
+
dtype=torch.long,
|
| 53 |
+
device=device,
|
| 54 |
+
)
|
| 55 |
+
edge_weight = torch.ones(edge_index.shape[1], device=device)
|
| 56 |
+
|
| 57 |
+
# Attribute features are left as zeros here; they will be replaced by the model
|
| 58 |
+
# with either CLIP text or trainable embeddings. For smoke we just keep zeros.
|
| 59 |
+
attr_feats = torch.zeros(bsz, num_attrs, feats.shape[-1], device=device)
|
| 60 |
+
|
| 61 |
+
return BipartiteGraphBatch(
|
| 62 |
+
object_feats=feats,
|
| 63 |
+
attr_feats=attr_feats,
|
| 64 |
+
edge_index=edge_index,
|
| 65 |
+
edge_weight=edge_weight,
|
| 66 |
+
)
|
| 67 |
+
|
src/train/graph_models.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List, Tuple
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from torch import nn
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class BipartiteGraphBatch:
|
| 12 |
+
"""Lightweight container for a batch of bipartite graphs.
|
| 13 |
+
|
| 14 |
+
All tensors live on the same device. Shapes:
|
| 15 |
+
- object_feats: [B, num_objects, in_dim]
|
| 16 |
+
- attr_feats: [B, num_attrs, attr_dim]
|
| 17 |
+
- edge_index: [2, num_edges] with edges from object -> attribute
|
| 18 |
+
- edge_weight: [num_edges]
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
object_feats: torch.Tensor
|
| 22 |
+
attr_feats: torch.Tensor
|
| 23 |
+
edge_index: torch.Tensor
|
| 24 |
+
edge_weight: torch.Tensor
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class BipartiteMessagePassingLayer(nn.Module):
|
| 28 |
+
"""Single message passing layer for object <-> attribute bipartite graphs.
|
| 29 |
+
|
| 30 |
+
For smoke training we keep a simple formulation:
|
| 31 |
+
1) Aggregate attribute messages into each object using weighted mean.
|
| 32 |
+
2) Project and combine with previous object features via residual MLP.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, in_dim: int, out_dim: int, attr_dim: int, dropout: float = 0.0) -> None:
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.in_dim = in_dim
|
| 38 |
+
self.out_dim = out_dim
|
| 39 |
+
self.attr_to_obj = nn.Linear(attr_dim, in_dim)
|
| 40 |
+
self.proj = nn.Linear(in_dim, out_dim)
|
| 41 |
+
self.update = nn.Sequential(
|
| 42 |
+
nn.Linear(in_dim + out_dim, out_dim),
|
| 43 |
+
nn.ReLU(),
|
| 44 |
+
nn.Dropout(dropout),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def forward(
|
| 48 |
+
self,
|
| 49 |
+
object_feats: torch.Tensor,
|
| 50 |
+
attr_feats: torch.Tensor,
|
| 51 |
+
edge_index: torch.Tensor,
|
| 52 |
+
edge_weight: torch.Tensor | None = None,
|
| 53 |
+
) -> torch.Tensor:
|
| 54 |
+
# object_feats: [B, O, Din], attr_feats: [B, A, Din_attr]
|
| 55 |
+
bsz, num_objects, _ = object_feats.shape
|
| 56 |
+
device = object_feats.device
|
| 57 |
+
|
| 58 |
+
if edge_weight is None:
|
| 59 |
+
edge_weight = torch.ones(edge_index.shape[1], device=device)
|
| 60 |
+
|
| 61 |
+
src_obj = edge_index[0] # indices in [0, B*O)
|
| 62 |
+
src_attr = edge_index[1] # indices in [0, B*A)
|
| 63 |
+
|
| 64 |
+
# Flatten batch/object and batch/attr dimensions for gathering.
|
| 65 |
+
flat_objects = object_feats.reshape(bsz * num_objects, -1)
|
| 66 |
+
flat_attrs = attr_feats.reshape(bsz * attr_feats.shape[1], -1)
|
| 67 |
+
|
| 68 |
+
# Messages from attributes to objects.
|
| 69 |
+
attr_msgs = flat_attrs.index_select(0, src_attr) # [E, Din_attr]
|
| 70 |
+
attr_msgs = self.attr_to_obj(attr_msgs) # [E, Din]
|
| 71 |
+
w = edge_weight.view(-1, 1)
|
| 72 |
+
weighted_msgs = attr_msgs * w
|
| 73 |
+
|
| 74 |
+
# Aggregate messages per object index.
|
| 75 |
+
agg = torch.zeros_like(flat_objects, device=device)
|
| 76 |
+
agg.index_add_(0, src_obj, weighted_msgs)
|
| 77 |
+
|
| 78 |
+
# Normalize by total incoming weight per object to compute mean.
|
| 79 |
+
weight_sums = torch.zeros(flat_objects.shape[0], device=device)
|
| 80 |
+
weight_sums.index_add_(0, src_obj, edge_weight)
|
| 81 |
+
weight_sums = weight_sums.clamp_min(1e-6).view(-1, 1)
|
| 82 |
+
agg = agg / weight_sums
|
| 83 |
+
|
| 84 |
+
# Project aggregated messages and combine with original object features.
|
| 85 |
+
proj_msgs = self.proj(agg)
|
| 86 |
+
combined = torch.cat([flat_objects, proj_msgs], dim=-1)
|
| 87 |
+
updated = self.update(combined)
|
| 88 |
+
|
| 89 |
+
return updated.view(bsz, num_objects, self.out_dim)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class NativeGNNClassifier(nn.Module):
|
| 93 |
+
"""Simple bipartite GNN classifier for multi-label attribute prediction."""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
in_dim: int,
|
| 98 |
+
hidden_dims: List[int],
|
| 99 |
+
num_attributes: int,
|
| 100 |
+
dropout: float = 0.2,
|
| 101 |
+
) -> None:
|
| 102 |
+
super().__init__()
|
| 103 |
+
layers: List[nn.Module] = []
|
| 104 |
+
dims = [in_dim] + hidden_dims
|
| 105 |
+
attr_dim = in_dim
|
| 106 |
+
for dim_in, dim_out in zip(dims[:-1], dims[1:]):
|
| 107 |
+
layers.append(BipartiteMessagePassingLayer(dim_in, dim_out, attr_dim=attr_dim, dropout=dropout))
|
| 108 |
+
self.layers = nn.ModuleList(layers)
|
| 109 |
+
self.classifier = nn.Linear(dims[-1], num_attributes)
|
| 110 |
+
|
| 111 |
+
def forward(
|
| 112 |
+
self,
|
| 113 |
+
graph: BipartiteGraphBatch,
|
| 114 |
+
) -> torch.Tensor:
|
| 115 |
+
x = graph.object_feats
|
| 116 |
+
for layer in self.layers:
|
| 117 |
+
x = layer(
|
| 118 |
+
object_feats=x,
|
| 119 |
+
attr_feats=graph.attr_feats,
|
| 120 |
+
edge_index=graph.edge_index,
|
| 121 |
+
edge_weight=graph.edge_weight,
|
| 122 |
+
)
|
| 123 |
+
# Predict attributes for each object, then average over objects in batch.
|
| 124 |
+
logits_per_object = self.classifier(x) # [B, O, num_attributes]
|
| 125 |
+
return logits_per_object.mean(dim=1)
|
| 126 |
+
|