alphamike commited on
Commit
419fc11
·
1 Parent(s): 4687353

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 CHANGED
@@ -105,14 +105,25 @@ Important: the processing pipeline is strict chair-only (`primary_object_class:
105
 
106
  ### 2) Train/Evaluate Models
107
 
108
- This repo provides dataset + experiment configs. Connect model training to your entrypoints (for example `scripts/train.py` and `scripts/eval.py`).
109
 
110
- Typical flow:
111
 
112
- 1. Run baseline experiments from `configs/experiment.yaml`.
113
- 2. Run the proposed GNN experiment.
114
- 3. Run ablation experiments.
115
- 4. Compare all runs in one metrics table.
 
 
 
 
 
 
 
 
 
 
 
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, 2)
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 = 8 if smoke else int(cfg["batching"].get("batch_size", 32))
193
- eval_batch_size = 8 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
 
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
+