GraphAttributeLearning / scripts /train_baseline.py
alphamike's picture
Add Gradio app for inference and model selection
419fc11
Raw
History Blame Contribute Delete
12.6 kB
from __future__ import annotations
import argparse
import json
import random
import time
from pathlib import Path
from typing import Any, Dict, List, Tuple
import numpy as np
import torch
from tqdm import tqdm
import sys
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = REPO_ROOT / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from data.io_utils import write_json # noqa: E402
from train.config_utils import ( # noqa: E402
apply_dot_overrides,
deep_merge,
load_yaml,
parse_overrides_json,
)
from train.dataset import ( # noqa: E402
ChairAttributeDataset,
build_dataloader,
class_pos_weights,
load_json,
split_paths,
)
from train.encoders import build_backbones # noqa: E402
from train.losses import bce_logits_loss # noqa: E402
from train.metrics import compute_metrics # noqa: E402
from train.models import BaselineClassifier # noqa: E402
def log(message: str) -> None:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts}] [train_baseline] {message}", flush=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train baseline model (A/B/C).")
parser.add_argument("--dataset-config", type=Path, default=REPO_ROOT / "configs" / "dataset.yaml")
parser.add_argument("--model-config", type=Path, default=REPO_ROOT / "configs" / "model.yaml")
parser.add_argument("--train-config", type=Path, default=REPO_ROOT / "configs" / "train.yaml")
parser.add_argument("--eval-config", type=Path, default=REPO_ROOT / "configs" / "eval.yaml")
parser.add_argument("--run-name", type=str, required=True)
parser.add_argument("--overrides-json", type=str, default="")
parser.add_argument("--mode", type=str, default="full", choices=["smoke", "full"])
parser.add_argument("--device", type=str, default="auto", choices=["auto", "cuda", "cpu"])
return parser.parse_args()
def set_seed(seed: int, deterministic: bool) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = deterministic
torch.backends.cudnn.benchmark = not deterministic
def load_combined_config(args: argparse.Namespace) -> Dict[str, Any]:
dataset_cfg = load_yaml(args.dataset_config)
model_cfg = load_yaml(args.model_config)
train_cfg = load_yaml(args.train_config)
eval_cfg = load_yaml(args.eval_config)
combined = deep_merge(dataset_cfg, model_cfg)
combined = deep_merge(combined, train_cfg)
combined = deep_merge(combined, eval_cfg)
overrides = parse_overrides_json(args.overrides_json)
if overrides:
combined = apply_dot_overrides(combined, overrides)
return combined
def build_loaders(cfg: Dict[str, Any], batch_size: int, eval_batch_size: int, smoke: bool) -> Tuple[Any, Any, Any, Dict[str, int]]:
processed_root = REPO_ROOT / cfg["dataset"]["processed_dir"]
label_vocab_path = processed_root / cfg["processing"]["label_vocab_file"]
label_freq_path = processed_root / cfg["processing"]["label_frequency_file"]
cache_dir = REPO_ROOT / cfg["dataset"]["root_dir"] / "images"
splits = split_paths(processed_root=processed_root, split_dir_name=cfg["processing"]["split_dir"])
label_vocab = load_json(label_vocab_path)
label_freq = load_json(label_freq_path)
train_ds = ChairAttributeDataset(splits["train"], label_vocab_path, REPO_ROOT, cache_dir)
val_ds = ChairAttributeDataset(splits["val"], label_vocab_path, REPO_ROOT, cache_dir)
test_ds = ChairAttributeDataset(splits["test"], label_vocab_path, REPO_ROOT, cache_dir)
num_workers = int(cfg["dataset"].get("num_workers", 0))
if smoke:
num_workers = min(num_workers, 8)
train_loader = build_dataloader(
dataset=train_ds,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
)
val_loader = build_dataloader(
dataset=val_ds,
batch_size=eval_batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
)
test_loader = build_dataloader(
dataset=test_ds,
batch_size=eval_batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
)
return train_loader, val_loader, test_loader, {"vocab": label_vocab, "freq": label_freq}
def build_model(cfg: Dict[str, Any], num_labels: int, device: torch.device) -> BaselineClassifier:
use_clip = bool(cfg["encoders"]["clip"]["enabled"])
use_dino = bool(cfg["encoders"]["dino"]["enabled"])
clip_name = str(cfg["encoders"]["clip"].get("model_name", "vit_base_patch16_clip_224.openai"))
dino_name = str(cfg["encoders"]["dino"].get("model_name", "vit_base_patch14_dinov2.lvd142m"))
if clip_name == "ViT-B-32":
clip_name = "vit_base_patch16_clip_224.openai"
if dino_name == "vit_base_patch14_dinov2":
dino_name = "vit_base_patch14_dinov2.lvd142m"
clip_backbone, dino_backbone, feature_dim = build_backbones(
use_clip=use_clip,
use_dino=use_dino,
clip_model_name=clip_name,
dino_model_name=dino_name,
clip_pretrained=bool(cfg["encoders"]["clip"].get("pretrained", True)),
dino_pretrained=bool(cfg["encoders"]["dino"].get("pretrained", True)),
)
hidden_dims = list(cfg["baseline_heads"]["mlp"].get("hidden_dims", [512, 256]))
dropout = float(cfg["baseline_heads"]["mlp"].get("dropout", 0.2))
model = BaselineClassifier(
clip_backbone=clip_backbone,
dino_backbone=dino_backbone,
feature_dim=feature_dim,
hidden_dims=hidden_dims,
dropout=dropout,
num_labels=num_labels,
)
model.freeze_backbones()
return model.to(device)
def run_eval(model: BaselineClassifier, loader: Any, device: torch.device, pos_weight: torch.Tensor) -> Dict[str, Any]:
model.eval()
all_logits: List[np.ndarray] = []
all_targets: List[np.ndarray] = []
losses: List[float] = []
with torch.no_grad():
for batch in loader:
targets = batch["targets"].to(device)
logits = model(batch["images"], device=device)
loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
losses.append(float(loss.item()))
all_logits.append(logits.detach().cpu().numpy())
all_targets.append(targets.detach().cpu().numpy())
logits_np = np.concatenate(all_logits, axis=0) if all_logits else np.zeros((0, 1))
targets_np = np.concatenate(all_targets, axis=0) if all_targets else np.zeros((0, 1))
metrics = compute_metrics(targets=targets_np, logits=logits_np)
metrics["loss"] = float(np.mean(losses)) if losses else 0.0
return metrics
def train() -> None:
args = parse_args()
log(f"Starting run_name={args.run_name} mode={args.mode} device_request={args.device}")
log(f"dataset_config={args.dataset_config}")
log(f"model_config={args.model_config}")
log(f"train_config={args.train_config}")
cfg = load_combined_config(args)
run_cfg = cfg.get("run", {})
seed = int(run_cfg.get("seed", 42))
deterministic = bool(run_cfg.get("deterministic", True))
set_seed(seed=seed, deterministic=deterministic)
log(f"Seed set to {seed}, deterministic={deterministic}")
smoke = args.mode == "smoke"
train_epochs = 2 if smoke else int(cfg["training"]["stage1"].get("epochs", 20))
batch_size = 4 if smoke else int(cfg["batching"].get("batch_size", 32))
eval_batch_size = 4 if smoke else int(cfg["batching"].get("eval_batch_size", 64))
cuda_available = torch.cuda.is_available()
selected_device = "cuda" if (args.device == "auto" and cuda_available) else args.device
if args.device == "auto" and not cuda_available:
selected_device = "cpu"
if selected_device == "cuda" and not cuda_available:
raise RuntimeError(
"CUDA requested but not available in torch build/environment. "
"Install CUDA-enabled PyTorch in the active virtualenv."
)
device = torch.device(selected_device)
log(
f"Resolved device={device.type}, torch_cuda_available={cuda_available}, "
f"torch_version={torch.__version__}, torch_cuda={torch.version.cuda}"
)
log("Building dataloaders from processed manifests...")
train_loader, val_loader, test_loader, label_info = build_loaders(
cfg=cfg,
batch_size=batch_size,
eval_batch_size=eval_batch_size,
smoke=smoke,
)
log(
f"Dataloader sizes -> train_batches={len(train_loader)} val_batches={len(val_loader)} "
f"test_batches={len(test_loader)}"
)
label_vocab = label_info["vocab"]
label_freq = label_info["freq"]
num_labels = len(label_vocab)
log(f"Label space size: {num_labels}")
log("Building encoder backbones (first run may download pretrained weights)...")
model = build_model(cfg=cfg, num_labels=num_labels, device=device)
log("Model construction complete")
pos_weight = class_pos_weights(label_frequencies=label_freq, label_vocab=label_vocab).to(device)
lr = float(cfg["training"]["stage1"].get("lr_head", 1e-3))
weight_decay = float(cfg["optimization"].get("weight_decay", 1e-4))
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad],
lr=lr,
weight_decay=weight_decay,
)
output_root = REPO_ROOT / run_cfg.get("output_dir", "outputs")
run_dir = output_root / args.run_name
run_dir.mkdir(parents=True, exist_ok=True)
history: List[Dict[str, Any]] = []
best_map = -1.0
best_path = run_dir / "best.pt"
log(f"Training for epochs={train_epochs}, batch_size={batch_size}, eval_batch_size={eval_batch_size}")
for epoch in range(1, train_epochs + 1):
model.train()
train_losses: List[float] = []
for batch in tqdm(train_loader, desc=f"{args.run_name} epoch {epoch}/{train_epochs}"):
targets = batch["targets"].to(device)
logits = model(batch["images"], device=device)
loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
train_losses.append(float(loss.item()))
val_metrics = run_eval(model=model, loader=val_loader, device=device, pos_weight=pos_weight)
epoch_row = {
"epoch": epoch,
"train_loss": float(np.mean(train_losses)) if train_losses else 0.0,
"val_loss": val_metrics["loss"],
"val_map": val_metrics["map"],
"val_macro_f1": val_metrics["macro_f1"],
"val_micro_f1": val_metrics["micro_f1"],
}
history.append(epoch_row)
log(
f"Epoch {epoch}/{train_epochs} -> train_loss={epoch_row['train_loss']:.4f} "
f"val_map={epoch_row['val_map']:.4f} val_macro_f1={epoch_row['val_macro_f1']:.4f}"
)
if val_metrics["map"] > best_map:
best_map = val_metrics["map"]
torch.save(
{
"model_state": model.state_dict(),
"label_vocab": label_vocab,
"config": cfg,
},
best_path,
)
log(f"Saved new best checkpoint at epoch {epoch}: {best_path}")
log("Running final test evaluation using best checkpoint...")
checkpoint = torch.load(best_path, map_location=device)
model.load_state_dict(checkpoint["model_state"])
test_metrics = run_eval(model=model, loader=test_loader, device=device, pos_weight=pos_weight)
payload = {
"run_name": args.run_name,
"mode": args.mode,
"num_labels": num_labels,
"best_val_map": best_map,
"history": history,
"test_metrics": test_metrics,
}
write_json(run_dir / "metrics.json", payload)
write_json(run_dir / "label_vocab.json", label_vocab)
with (run_dir / "overrides.json").open("w", encoding="utf-8") as handle:
json.dump(parse_overrides_json(args.overrides_json), handle, indent=2, ensure_ascii=True)
log(f"Training complete for {args.run_name}")
print(json.dumps({"run_name": args.run_name, "test_map": test_metrics["map"]}, indent=2), flush=True)
if __name__ == "__main__":
train()