ravel / scripts /run_revised_experiments.py
minhy112's picture
Upload RAVEL revision project without data or checkpoints
ea8bfa1 verified
Raw
History Blame Contribute Delete
52.3 kB
#!/usr/bin/env python3
"""Run revised RAVEL staged experiments with reusable outputs.
The script is resumable: a run is skipped when its metrics JSON and prediction
CSV already exist, unless `--overwrite` is passed.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import random
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, roc_auc_score
from torch.optim import AdamW
from transformers import CLIPProcessor, DebertaV2Tokenizer
from transformers.utils import logging as hf_logging
hf_logging.set_verbosity_error()
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.revised_ravel_model import token_loss
@dataclass(frozen=True)
class MethodSpec:
key: str
display: str
architecture: str
train_target: str
enable_lora: bool = True
use_coattention: bool = True
use_auxiliary: bool = True
use_disagreement: bool = True
parameter_matched: bool = False
disagreement_formulation: str = "signed_difference"
additional_loss: str = "none"
lambda_primary: float = 0.5
lambda_unimodal: float = 0.25
consistency_beta: float = 0.25
infonce_alpha: float = 0.10
infonce_temperature: float = 0.07
METHODS: Dict[str, MethodSpec] = {
"frozen_concat": MethodSpec(
key="frozen_concat",
display="Frozen concat",
architecture="token",
train_target="no_coattn",
enable_lora=False,
use_coattention=False,
use_auxiliary=False,
use_disagreement=False,
),
"lora_concat": MethodSpec(
key="lora_concat",
display="LoRA concat",
architecture="token",
train_target="no_coattn",
enable_lora=True,
use_coattention=False,
use_auxiliary=False,
use_disagreement=False,
),
"frozen_token_coattn": MethodSpec(
key="frozen_token_coattn",
display="Frozen token co-attention",
architecture="token",
train_target="primary",
enable_lora=False,
use_coattention=True,
use_auxiliary=False,
use_disagreement=False,
),
"legacy_global": MethodSpec(
key="legacy_global",
display="RAVEL-Global",
architecture="legacy_global",
train_target="legacy_full",
enable_lora=True,
use_coattention=False,
use_auxiliary=False,
use_disagreement=False,
),
"token_coattn": MethodSpec(
key="token_coattn",
display="Token co-attention",
architecture="token",
train_target="primary",
enable_lora=True,
use_coattention=True,
use_auxiliary=False,
use_disagreement=False,
),
"token_aux": MethodSpec(
key="token_aux",
display="Token co-attention + unimodal heads",
architecture="token",
train_target="primary_aux",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=False,
),
"param_mlp": MethodSpec(
key="param_mlp",
display="Parameter-matched MLP",
architecture="token",
train_target="param_mlp",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=False,
parameter_matched=True,
),
"full_revised": MethodSpec(
key="full_revised",
display="Full revised RAVEL",
architecture="token",
train_target="full",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
),
"text_only": MethodSpec(
key="text_only",
display="Text-only",
architecture="token",
train_target="text_only",
enable_lora=True,
use_coattention=False,
use_auxiliary=True,
use_disagreement=False,
),
"vision_only": MethodSpec(
key="vision_only",
display="Vision-only",
architecture="token",
train_target="vision_only",
enable_lora=True,
use_coattention=False,
use_auxiliary=True,
use_disagreement=False,
),
"absolute_difference": MethodSpec(
key="absolute_difference",
display="Absolute posterior difference",
architecture="token",
train_target="disagreement_feature",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
disagreement_formulation="absolute_difference",
),
"js_divergence": MethodSpec(
key="js_divergence",
display="Jensen-Shannon divergence",
architecture="token",
train_target="disagreement_feature",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
disagreement_formulation="js_divergence",
),
"log_probability_ratio": MethodSpec(
key="log_probability_ratio",
display="Log-probability ratio",
architecture="token",
train_target="disagreement_feature",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
disagreement_formulation="log_probability_ratio",
),
"attention_discrepancy": MethodSpec(
key="attention_discrepancy",
display="Attention discrepancy",
architecture="token",
train_target="disagreement_feature",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
disagreement_formulation="attention_discrepancy",
),
"consistency_loss": MethodSpec(
key="consistency_loss",
display="JS consistency loss",
architecture="token",
train_target="consistency_loss",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=False,
additional_loss="js_consistency",
),
"infonce_alignment": MethodSpec(
key="infonce_alignment",
display="InfoNCE alignment",
architecture="token",
train_target="infonce_alignment",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=False,
additional_loss="infonce_alignment",
),
"lambda_u_0_0": MethodSpec(
key="lambda_u_0_0",
display="Full revised RAVEL lambda_u=0.0",
architecture="token",
train_target="full",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
lambda_unimodal=0.0,
),
"lambda_u_0_25": MethodSpec(
key="lambda_u_0_25",
display="Full revised RAVEL lambda_u=0.25",
architecture="token",
train_target="full",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
lambda_unimodal=0.25,
),
"lambda_u_0_5": MethodSpec(
key="lambda_u_0_5",
display="Full revised RAVEL lambda_u=0.5",
architecture="token",
train_target="full",
enable_lora=True,
use_coattention=True,
use_auxiliary=True,
use_disagreement=True,
lambda_unimodal=0.5,
),
}
STAGE_B_METHODS = [
"frozen_concat",
"lora_concat",
"frozen_token_coattn",
"legacy_global",
"token_coattn",
"token_aux",
"param_mlp",
"full_revised",
]
STAGE_C_METHODS = [
"legacy_global",
"text_only",
"vision_only",
"token_coattn",
"token_aux",
"param_mlp",
"full_revised",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run revised RAVEL staged experiments.")
parser.add_argument("--stage", choices=["stage_b", "stage_c", "all"], default="all")
parser.add_argument("--datasets", nargs="+", default=["mvsa_multiple", "hfm_existing", "hfm_deleak"])
parser.add_argument("--seeds", nargs="+", type=int, default=[1, 3, 5, 7, 11])
parser.add_argument("--stage-b-seed", type=int, default=7)
parser.add_argument("--methods", nargs="+", default=None, choices=sorted(METHODS))
parser.add_argument("--epochs", type=int, default=5)
parser.add_argument("--patience", type=int, default=2)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--grad-accum-steps", type=int, default=2)
parser.add_argument("--max-length", type=int, default=96)
parser.add_argument("--learning-rate", type=float, default=5e-5)
parser.add_argument("--weight-decay", type=float, default=0.01)
parser.add_argument("--device", default="cuda")
parser.add_argument("--num-workers", type=int, default=4)
parser.add_argument("--output-root", default="ravel_revision_results")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--limit-train-samples", type=int, default=None)
parser.add_argument("--limit-val-samples", type=int, default=None)
parser.add_argument("--limit-test-samples", type=int, default=None)
parser.add_argument(
"--hfm-deleak-manifest",
default="ravel_revision_results/data_audit/hfm_split_manifest_deleaked.csv",
)
return parser.parse_args()
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def write_csv(path: Path, rows: Iterable[Dict[str, Any]], fieldnames: Sequence[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(fieldnames), extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow(row)
def append_csv(path: Path, rows: Iterable[Dict[str, Any]], fieldnames: Sequence[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
exists = path.exists()
with path.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(fieldnames), extrasaction="ignore")
if not exists:
writer.writeheader()
for row in rows:
writer.writerow(row)
def freeze_non_lora(module: nn.Module) -> None:
for name, parameter in module.named_parameters():
parameter.requires_grad = "lora_" in name
def set_module_trainable(module: Optional[nn.Module], trainable: bool) -> None:
if module is None:
return
for parameter in module.parameters():
parameter.requires_grad = trainable
def apply_method_trainability(model: nn.Module, method: MethodSpec) -> None:
"""Freeze modules that are intentionally bypassed by an ablation target."""
target = method.train_target
if target == "no_coattn":
for name in [
"fusion",
"visual_head",
"text_head",
"refinement",
"extra_mlp_control",
]:
set_module_trainable(getattr(model, name, None), False)
return
if target == "primary":
for name in ["visual_head", "text_head", "refinement", "extra_mlp_control"]:
set_module_trainable(getattr(model, name, None), False)
return
if target == "primary_aux":
for name in ["refinement", "extra_mlp_control"]:
set_module_trainable(getattr(model, name, None), False)
return
if target == "param_mlp":
set_module_trainable(getattr(model, "refinement", None), False)
return
if target == "disagreement_feature":
set_module_trainable(getattr(model, "extra_mlp_control", None), False)
return
if target in {"consistency_loss", "infonce_alignment"}:
for name in ["refinement", "extra_mlp_control"]:
set_module_trainable(getattr(model, name, None), False)
return
def maybe_limit(samples: List[Any], limit: Optional[int]) -> List[Any]:
if limit is None or limit <= 0 or len(samples) <= limit:
return samples
buckets: Dict[Any, List[Any]] = {}
for sample in samples:
label = getattr(sample, "label", getattr(sample, "combined_majority", ""))
buckets.setdefault(label, []).append(sample)
per_label = max(1, limit // max(1, len(buckets)))
selected: List[Any] = []
for label in sorted(buckets, key=str):
selected.extend(buckets[label][:per_label])
selected.extend(samples[: max(0, limit - len(selected))])
return selected[:limit]
def load_dataset(
dataset_key: str,
seed: int,
batch_size: int,
max_length: int,
num_workers: int,
method: MethodSpec,
hfm_deleak_manifest: str,
limits: Tuple[Optional[int], Optional[int], Optional[int]],
) -> Tuple[Any, Dict[str, Any], Any, Any, Any, List[Any], List[Any], List[Any], int, List[str]]:
if dataset_key == "mvsa_multiple":
from src.mvsa_multiple_pipeline import (
CLARAModel,
DEFAULT_MVSA_MULTIPLE_CONFIG,
LABEL_ID_TO_NAME,
MVSALoader,
create_dataloaders,
summarize_splits,
)
cfg = dict(DEFAULT_MVSA_MULTIPLE_CONFIG)
cfg.update(
{
"architecture": method.architecture,
"enable_clip_lora": method.enable_lora,
"batch_size": batch_size,
"max_length": max_length,
"num_workers": num_workers,
"pin_memory": True,
"persistent_workers": bool(num_workers > 0),
"prefetch_factor": 2,
"seed": seed,
"learning_rate": 5e-5,
"weight_decay": 0.01,
"use_mixup_negative": False,
"use_weighted_sampler": False,
"text_unfreeze_mode": "freeze_all",
"unfreeze_epoch": 0,
"paper_exact_counts": True,
}
)
loader = MVSALoader(cfg["text_dir"], cfg["label_file"])
loader.load(
preprocessing_mode=str(cfg.get("preprocessing_mode", "paper")),
require_unanimous=bool(cfg["require_unanimous"]),
require_cross_agree=bool(cfg["require_cross_agree"]),
paper_exact_counts=True,
)
train_samples, val_samples, test_samples = loader.split(
train_ratio=float(cfg["train_ratio"]),
val_ratio=float(cfg["val_ratio"]),
seed=seed,
paper_811=True,
)
train_samples = maybe_limit(train_samples, limits[0])
val_samples = maybe_limit(val_samples, limits[1])
test_samples = maybe_limit(test_samples, limits[2])
processor = CLIPProcessor.from_pretrained(cfg["vision_model_id"])
tokenizer = DebertaV2Tokenizer.from_pretrained(cfg["text_model_id"])
train_loader, val_loader, test_loader = create_dataloaders(
train_samples=train_samples,
val_samples=val_samples,
test_samples=test_samples,
clip_processor=processor,
tokenizer=tokenizer,
batch_size=batch_size,
max_length=max_length,
num_workers=num_workers,
pin_memory=True,
persistent_workers=bool(num_workers > 0),
prefetch_factor=2,
use_mixup_negative=False,
mixup_alpha=0.0,
negative_class_boost=1.0,
min_ratio_negative=0.0,
weighted_train_sampler=False,
)
label_names = [LABEL_ID_TO_NAME[idx] for idx in range(int(cfg["num_classes"]))]
return (
CLARAModel,
cfg,
train_loader,
val_loader,
test_loader,
train_samples,
val_samples,
test_samples,
int(cfg["num_classes"]),
label_names,
)
if dataset_key == "mvsa_single":
from src.mvsa_single_pipeline import (
CLARAModel,
DEFAULT_MVSA_SINGLE_CONFIG,
LABEL_ID_TO_NAME,
MVSASingleLoader,
create_dataloaders,
)
cfg = dict(DEFAULT_MVSA_SINGLE_CONFIG)
cfg.update(
{
"architecture": method.architecture,
"enable_clip_lora": method.enable_lora,
"enable_text_lora": method.enable_lora,
"batch_size": batch_size,
"max_length": max_length,
"num_workers": num_workers,
"pin_memory": True,
"persistent_workers": bool(num_workers > 0),
"prefetch_factor": 2,
"seed": seed,
"learning_rate": 5e-5,
"weight_decay": 0.01,
"use_mixup_negative": False,
"use_weighted_sampler": False,
"text_unfreeze_mode": "freeze_all",
"unfreeze_epoch": 0,
"loss_type": "ce",
"ce_use_class_weights": False,
"label_smoothing": 0.0,
}
)
loader = MVSASingleLoader(cfg["text_dir"], cfg["label_file"])
samples = loader.load(
preprocessing_mode=str(cfg.get("preprocessing_mode", "paper")),
require_unanimous=bool(cfg.get("require_unanimous", False)),
require_cross_agree=bool(cfg.get("require_cross_agree", False)),
)
train_samples, val_samples, test_samples = loader.split(
train_ratio=float(cfg["train_ratio"]),
val_ratio=float(cfg["val_ratio"]),
seed=seed,
)
train_samples = maybe_limit(train_samples, limits[0])
val_samples = maybe_limit(val_samples, limits[1])
test_samples = maybe_limit(test_samples, limits[2])
processor = CLIPProcessor.from_pretrained(cfg["vision_model_id"])
tokenizer = DebertaV2Tokenizer.from_pretrained(cfg["text_model_id"])
train_loader, val_loader, test_loader = create_dataloaders(
train_samples=train_samples,
val_samples=val_samples,
test_samples=test_samples,
clip_processor=processor,
tokenizer=tokenizer,
batch_size=batch_size,
max_length=max_length,
num_workers=num_workers,
pin_memory=True,
persistent_workers=bool(num_workers > 0),
prefetch_factor=2,
use_mixup_negative=False,
mixup_alpha=0.0,
negative_class_boost=1.0,
min_ratio_negative=0.0,
weighted_train_sampler=False,
)
label_names = [LABEL_ID_TO_NAME[idx] for idx in range(int(cfg["num_classes"]))]
_ = samples # Keep the load step explicit for provenance and error checking.
return (
CLARAModel,
cfg,
train_loader,
val_loader,
test_loader,
train_samples,
val_samples,
test_samples,
int(cfg["num_classes"]),
label_names,
)
if dataset_key in {"hfm_existing", "hfm_deleak"}:
from src.hfm_pipeline import (
CLARAModel,
DEFAULT_HFM_CONFIG,
LABEL_ID_TO_NAME,
HFMLoader,
create_dataloaders,
)
cfg = dict(DEFAULT_HFM_CONFIG)
cfg.update(
{
"architecture": method.architecture,
"enable_clip_lora": method.enable_lora,
"batch_size": batch_size,
"max_length": max_length,
"num_workers": num_workers,
"pin_memory": True,
"seed": seed,
"learning_rate": 5e-5,
"weight_decay": 0.01,
"text_unfreeze_mode": "freeze_all",
"num_classes": 2,
}
)
loader = HFMLoader(cfg["text_dir"], cfg["image_root"])
if dataset_key == "hfm_deleak":
loader.load_from_manifest(hfm_deleak_manifest)
else:
loader.load()
train_samples = maybe_limit(loader.get_split("train"), limits[0])
val_samples = maybe_limit(loader.get_split("val"), limits[1])
test_samples = maybe_limit(loader.get_split("test"), limits[2])
processor = CLIPProcessor.from_pretrained(cfg["vision_model_id"])
tokenizer = DebertaV2Tokenizer.from_pretrained(cfg["text_model_id"])
train_loader, val_loader, test_loader = create_dataloaders(
train_samples=train_samples,
val_samples=val_samples,
test_samples=test_samples,
clip_processor=processor,
tokenizer=tokenizer,
batch_size=batch_size,
max_length=max_length,
num_workers=num_workers,
pin_memory=True,
weighted_train_sampler=False,
)
label_names = [LABEL_ID_TO_NAME[idx] for idx in range(int(cfg["num_classes"]))]
return (
CLARAModel,
cfg,
train_loader,
val_loader,
test_loader,
train_samples,
val_samples,
test_samples,
int(cfg["num_classes"]),
label_names,
)
raise ValueError(f"Unsupported dataset: {dataset_key}")
def _js_vector_torch(p: torch.Tensor, q: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
p = p.clamp(min=eps)
q = q.clamp(min=eps)
m = (0.5 * (p + q)).clamp(min=eps)
return 0.5 * p * (p / m).log() + 0.5 * q * (q / m).log()
def _attention_entropy(attn: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
probs = attn.clamp(min=eps)
entropy = -(probs * probs.log()).sum(dim=-1)
support = attn.size(-1)
if support > 1:
entropy = entropy / math.log(float(support))
return entropy.mean(dim=tuple(range(1, entropy.ndim)))
def disagreement_feature(
outputs: Dict[str, torch.Tensor],
batch: Dict[str, torch.Tensor],
formulation: str,
num_classes: int,
) -> torch.Tensor:
visual_probs = outputs["visual_probs"]
text_probs = outputs["text_probs"]
if formulation == "signed_difference":
return visual_probs - text_probs
if formulation == "absolute_difference":
return (visual_probs - text_probs).abs()
if formulation == "js_divergence":
return _js_vector_torch(visual_probs, text_probs)
if formulation == "log_probability_ratio":
return (visual_probs.clamp(min=1e-8).log() - text_probs.clamp(min=1e-8).log())
if formulation == "attention_discrepancy":
v2t_list = outputs.get("attention_v2t", [])
t2v_list = outputs.get("attention_t2v", [])
if not v2t_list or not t2v_list:
raise RuntimeError("attention_discrepancy requires return_attention=True outputs.")
v2t = torch.stack(v2t_list, dim=0).mean(dim=0)
t2v = torch.stack(t2v_list, dim=0).mean(dim=0)
scalar = (_attention_entropy(v2t) - _attention_entropy(t2v)).unsqueeze(-1)
return scalar.expand(-1, num_classes)
raise ValueError(f"Unsupported disagreement formulation: {formulation}")
def consistency_js_loss(outputs: Dict[str, torch.Tensor]) -> torch.Tensor:
return _js_vector_torch(outputs["visual_probs"], outputs["text_probs"]).sum(dim=-1).mean()
def infonce_loss(outputs: Dict[str, torch.Tensor], temperature: float) -> torch.Tensor:
visual = F.normalize(outputs["visual_global"].float(), dim=-1)
text = F.normalize(outputs["text_global"].float(), dim=-1)
logits = visual @ text.T / max(float(temperature), 1e-6)
labels = torch.arange(logits.size(0), device=logits.device)
return 0.5 * (F.cross_entropy(logits, labels) + F.cross_entropy(logits.T, labels))
def logits_for_target(
model: nn.Module,
batch: Dict[str, torch.Tensor],
method_or_target: Any,
criterion: nn.Module,
labels: torch.Tensor,
num_classes: Optional[int] = None,
) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, torch.Tensor], Dict[str, torch.Tensor]]:
if isinstance(method_or_target, MethodSpec):
method = method_or_target
target = method.train_target
else:
method = None
target = str(method_or_target)
pixel_values = batch["pixel_values"]
input_ids = batch["input_ids"]
attention_mask = batch["attention_mask"]
if target == "legacy_full":
outputs = model(pixel_values=pixel_values, input_ids=input_ids, attention_mask=attention_mask)
logits = outputs["logits"]
return logits, criterion(logits, labels), outputs, {}
if target == "no_coattn":
logits = model.logits_without_coattention(pixel_values, input_ids, attention_mask)
return logits, criterion(logits, labels), {}, {}
if target in {"text_only", "vision_only"} and hasattr(model, "encode_modalities"):
encoded = model.encode_modalities(pixel_values, input_ids, attention_mask)
if target == "text_only":
logits = model.text_head(encoded["text_global"])
else:
logits = model.visual_head(encoded["visual_global"])
return logits, criterion(logits, labels), {}, {}
outputs = model(pixel_values=pixel_values, input_ids=input_ids, attention_mask=attention_mask)
if target == "full":
loss, parts = token_loss(
outputs,
labels,
criterion,
lambda_primary=method.lambda_primary if method is not None else 0.5,
lambda_unimodal=method.lambda_unimodal if method is not None else 0.25,
)
return outputs["logits"], loss, outputs, parts
if target == "primary":
logits = outputs["pred_logits"]
return logits, criterion(logits, labels), outputs, {}
if target == "primary_aux":
logits = outputs["pred_logits"]
loss = criterion(logits, labels)
loss = loss + 0.25 * (
criterion(outputs["visual_logits"], labels) + criterion(outputs["text_logits"], labels)
)
return logits, loss, outputs, {}
if target == "param_mlp":
logits = model.extra_mlp_control(outputs["fused"])
loss = criterion(logits, labels)
loss = loss + 0.25 * criterion(outputs["pred_logits"], labels)
loss = loss + 0.25 * (
criterion(outputs["visual_logits"], labels) + criterion(outputs["text_logits"], labels)
)
return logits, loss, outputs, {}
if target == "disagreement_feature":
if method is None:
raise ValueError("disagreement_feature requires a MethodSpec.")
if num_classes is None:
num_classes = int(outputs["visual_probs"].shape[-1])
if method.disagreement_formulation == "attention_discrepancy":
outputs = model(
pixel_values=pixel_values,
input_ids=input_ids,
attention_mask=attention_mask,
return_attention=True,
)
feature = disagreement_feature(
outputs,
batch,
formulation=method.disagreement_formulation,
num_classes=int(num_classes),
).to(dtype=outputs["fused"].dtype)
logits = model.refinement(outputs["fused"], feature)
loss = criterion(logits, labels)
loss = loss + method.lambda_primary * criterion(outputs["pred_logits"], labels)
loss = loss + method.lambda_unimodal * (
criterion(outputs["visual_logits"], labels) + criterion(outputs["text_logits"], labels)
)
outputs = dict(outputs)
outputs["logits"] = logits
outputs["disagreement"] = feature
return logits, loss, outputs, {}
if target == "consistency_loss":
if method is None:
raise ValueError("consistency_loss requires a MethodSpec.")
logits = outputs["pred_logits"]
loss = criterion(logits, labels)
loss = loss + method.lambda_unimodal * (
criterion(outputs["visual_logits"], labels) + criterion(outputs["text_logits"], labels)
)
loss = loss + method.consistency_beta * consistency_js_loss(outputs)
outputs = dict(outputs)
outputs["logits"] = logits
outputs["disagreement"] = torch.zeros_like(outputs["visual_probs"])
return logits, loss, outputs, {}
if target == "infonce_alignment":
if method is None:
raise ValueError("infonce_alignment requires a MethodSpec.")
logits = outputs["pred_logits"]
loss = criterion(logits, labels)
loss = loss + method.lambda_unimodal * (
criterion(outputs["visual_logits"], labels) + criterion(outputs["text_logits"], labels)
)
loss = loss + method.infonce_alpha * infonce_loss(outputs, method.infonce_temperature)
outputs = dict(outputs)
outputs["logits"] = logits
outputs["disagreement"] = torch.zeros_like(outputs["visual_probs"])
return logits, loss, outputs, {}
if target == "no_disagreement":
logits = model.refinement(outputs["fused"], torch.zeros_like(outputs["disagreement"]))
loss = criterion(logits, labels)
loss = loss + 0.5 * criterion(outputs["pred_logits"], labels)
loss = loss + 0.25 * (
criterion(outputs["visual_logits"], labels) + criterion(outputs["text_logits"], labels)
)
return logits, loss, outputs, {}
raise ValueError(target)
def expected_calibration_error(probs: np.ndarray, y_true: np.ndarray, bins: int = 15) -> float:
conf = probs.max(axis=1)
pred = probs.argmax(axis=1)
correct = (pred == y_true).astype(float)
edges = np.linspace(0.0, 1.0, bins + 1)
ece = 0.0
for low, high in zip(edges[:-1], edges[1:]):
mask = (conf > low) & (conf <= high)
if not mask.any():
continue
ece += (mask.mean()) * abs(correct[mask].mean() - conf[mask].mean())
return float(ece)
def adaptive_ece(probs: np.ndarray, y_true: np.ndarray, bins: int = 15) -> float:
conf = probs.max(axis=1)
pred = probs.argmax(axis=1)
correct = (pred == y_true).astype(float)
order = np.argsort(conf)
chunks = np.array_split(order, min(bins, len(order)))
ece = 0.0
for chunk in chunks:
if len(chunk) == 0:
continue
ece += (len(chunk) / len(conf)) * abs(correct[chunk].mean() - conf[chunk].mean())
return float(ece)
def brier_score(probs: np.ndarray, y_true: np.ndarray, num_classes: int) -> float:
one_hot = np.eye(num_classes)[y_true]
return float(np.mean(np.sum((probs - one_hot) ** 2, axis=1)))
def nll_score(probs: np.ndarray, y_true: np.ndarray) -> float:
return float(-np.mean(np.log(np.clip(probs[np.arange(len(y_true)), y_true], 1e-12, 1.0))))
def aurc_score(probs: np.ndarray, y_true: np.ndarray) -> Tuple[float, float, float, float, float]:
conf = probs.max(axis=1)
pred = probs.argmax(axis=1)
errors = (pred != y_true).astype(float)
order = np.argsort(-conf)
sorted_errors = errors[order]
cum_errors = np.cumsum(sorted_errors)
coverage = np.arange(1, len(errors) + 1) / max(1, len(errors))
risk = cum_errors / np.arange(1, len(errors) + 1)
trapezoid = getattr(np, "trapezoid", None)
if trapezoid is None:
trapezoid = getattr(np, "trapz")
aurc = float(trapezoid(risk, coverage)) if len(errors) > 1 else float(risk[-1])
def risk_at(cov: float) -> float:
idx = max(0, min(len(errors) - 1, math.ceil(cov * len(errors)) - 1))
return float(risk[idx])
def acc_at(cov: float) -> float:
return 1.0 - risk_at(cov)
return aurc, risk_at(0.8), risk_at(0.9), acc_at(0.8), acc_at(0.9)
def compute_metrics(
logits: np.ndarray,
y_true: np.ndarray,
num_classes: int,
) -> Dict[str, float]:
probs = torch.softmax(torch.tensor(logits, dtype=torch.float32), dim=-1).numpy()
pred = probs.argmax(axis=1)
metrics: Dict[str, float] = {
"accuracy": float(accuracy_score(y_true, pred)),
"macro_precision": float(precision_score(y_true, pred, average="macro", zero_division=0)),
"macro_recall": float(recall_score(y_true, pred, average="macro", zero_division=0)),
"macro_f1": float(f1_score(y_true, pred, average="macro", zero_division=0)),
"weighted_f1": float(f1_score(y_true, pred, average="weighted", zero_division=0)),
"raw_ece": expected_calibration_error(probs, y_true),
"adaptive_ece": adaptive_ece(probs, y_true),
"nll": nll_score(probs, y_true),
"brier": brier_score(probs, y_true, num_classes),
}
aurc, r80, r90, a80, a90 = aurc_score(probs, y_true)
metrics.update(
{
"aurc": aurc,
"risk_at_80_coverage": r80,
"risk_at_90_coverage": r90,
"accuracy_at_80_coverage": a80,
"accuracy_at_90_coverage": a90,
}
)
try:
if num_classes == 2:
metrics["auroc"] = float(roc_auc_score(y_true, probs[:, 1]))
else:
metrics["auroc"] = float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro"))
except Exception:
metrics["auroc"] = float("nan")
return metrics
def samples_to_ids(samples: List[Any]) -> List[str]:
ids: List[str] = []
for idx, sample in enumerate(samples):
ids.append(str(getattr(sample, "image_id", getattr(sample, "sample_id", idx))))
return ids
def evaluate(
model: nn.Module,
loader: Any,
samples: List[Any],
method: MethodSpec,
device: torch.device,
criterion: nn.Module,
num_classes: int,
dataset_key: str,
seed: int,
) -> Tuple[Dict[str, float], List[Dict[str, Any]], np.ndarray, np.ndarray]:
model.eval()
logits_all: List[np.ndarray] = []
labels_all: List[np.ndarray] = []
visual_probs_all: List[np.ndarray] = []
text_probs_all: List[np.ndarray] = []
primary_logits_all: List[np.ndarray] = []
refined_logits_all: List[np.ndarray] = []
with torch.no_grad():
for batch in loader:
labels = batch["labels"].long().to(device)
batch_gpu = {
key: value.to(device, non_blocking=True) if torch.is_tensor(value) else value
for key, value in batch.items()
}
logits, _loss, outputs, _parts = logits_for_target(
model, batch_gpu, method, criterion, labels, num_classes=num_classes
)
logits_all.append(logits.float().cpu().numpy())
labels_all.append(labels.cpu().numpy())
if isinstance(outputs, dict) and "visual_probs" in outputs:
visual_probs_all.append(outputs["visual_probs"].float().cpu().numpy())
text_probs_all.append(outputs["text_probs"].float().cpu().numpy())
primary_logits_all.append(outputs["pred_logits"].float().cpu().numpy())
refined_logits_all.append(outputs["logits"].float().cpu().numpy())
logits_np = np.concatenate(logits_all, axis=0)
y_np = np.concatenate(labels_all, axis=0)
metrics = compute_metrics(logits_np, y_np, num_classes)
probs = torch.softmax(torch.tensor(logits_np, dtype=torch.float32), dim=-1).numpy()
pred = probs.argmax(axis=1)
sample_ids = samples_to_ids(samples)
visual_probs = np.concatenate(visual_probs_all, axis=0) if visual_probs_all else np.full_like(probs, np.nan)
text_probs = np.concatenate(text_probs_all, axis=0) if text_probs_all else np.full_like(probs, np.nan)
primary_logits = (
np.concatenate(primary_logits_all, axis=0) if primary_logits_all else np.full_like(logits_np, np.nan)
)
refined_logits = (
np.concatenate(refined_logits_all, axis=0) if refined_logits_all else np.full_like(logits_np, np.nan)
)
primary_probs = torch.softmax(torch.tensor(primary_logits, dtype=torch.float32), dim=-1).numpy()
refined_probs = torch.softmax(torch.tensor(refined_logits, dtype=torch.float32), dim=-1).numpy()
disagreement = visual_probs - text_probs
rows: List[Dict[str, Any]] = []
for i in range(len(y_np)):
row: Dict[str, Any] = {
"sample_id": sample_ids[i] if i < len(sample_ids) else str(i),
"dataset": dataset_key,
"split": "test",
"seed": seed,
"method": method.key,
"true_label": int(y_np[i]),
"predicted_label": int(pred[i]),
"prediction_entropy": float(-(probs[i] * np.log(np.clip(probs[i], 1e-12, 1.0))).sum()),
"max_confidence": float(probs[i].max()),
"primary_prediction": int(np.nanargmax(primary_probs[i])) if np.isfinite(primary_probs[i]).all() else "",
"primary_confidence": float(np.nanmax(primary_probs[i])) if np.isfinite(primary_probs[i]).all() else "",
"refined_prediction": int(np.nanargmax(refined_probs[i])) if np.isfinite(refined_probs[i]).all() else "",
"refined_confidence": float(np.nanmax(refined_probs[i])) if np.isfinite(refined_probs[i]).all() else "",
"correct_primary": int(np.nanargmax(primary_probs[i]) == y_np[i]) if np.isfinite(primary_probs[i]).all() else "",
"correct_refined": int(np.nanargmax(refined_probs[i]) == y_np[i]) if np.isfinite(refined_probs[i]).all() else "",
"number_of_images": 1,
}
for c in range(num_classes):
row[f"logit_class_{c}"] = float(logits_np[i, c])
row[f"prob_class_{c}"] = float(probs[i, c])
row[f"visual_prob_class_{c}"] = float(visual_probs[i, c]) if np.isfinite(visual_probs[i, c]) else ""
row[f"text_prob_class_{c}"] = float(text_probs[i, c]) if np.isfinite(text_probs[i, c]) else ""
row[f"disagreement_class_{c}"] = float(disagreement[i, c]) if np.isfinite(disagreement[i, c]) else ""
if np.isfinite(disagreement[i]).all():
row["disagreement_l1"] = float(np.abs(disagreement[i]).sum())
else:
row["disagreement_l1"] = ""
rows.append(row)
return metrics, rows, logits_np, y_np
def train_one(
model: nn.Module,
train_loader: Any,
val_loader: Any,
method: MethodSpec,
device: torch.device,
epochs: int,
patience: int,
grad_accum_steps: int,
learning_rate: float,
weight_decay: float,
num_classes: int,
) -> Tuple[Dict[str, Any], Dict[str, torch.Tensor], List[Dict[str, Any]]]:
criterion = nn.CrossEntropyLoss()
optimizer = AdamW(
[parameter for parameter in model.parameters() if parameter.requires_grad],
lr=learning_rate,
weight_decay=weight_decay,
)
use_bf16 = bool(device.type == "cuda" and torch.cuda.is_bf16_supported())
amp_dtype = torch.bfloat16 if use_bf16 else torch.float16
def trainable_state_dict() -> Dict[str, torch.Tensor]:
trainable_names = {name for name, parameter in model.named_parameters() if parameter.requires_grad}
return {
key: value.detach().cpu().clone()
for key, value in model.state_dict().items()
if key in trainable_names
}
best_state: Dict[str, torch.Tensor] = {}
best_val = -1.0
best_epoch = 0
bad_epochs = 0
history: List[Dict[str, Any]] = []
for epoch in range(1, epochs + 1):
model.train()
train_losses: List[float] = []
optimizer.zero_grad(set_to_none=True)
start = time.time()
for step, batch in enumerate(train_loader, start=1):
labels = batch["labels"].long().to(device, non_blocking=True)
batch_gpu = {
key: value.to(device, non_blocking=True) if torch.is_tensor(value) else value
for key, value in batch.items()
}
with torch.autocast(device_type="cuda", dtype=amp_dtype, enabled=device.type == "cuda"):
_logits, loss, _outputs, _parts = logits_for_target(
model, batch_gpu, method, criterion, labels, num_classes=num_classes
)
loss = loss / max(1, grad_accum_steps)
loss.backward()
if step % max(1, grad_accum_steps) == 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
train_losses.append(float(loss.detach().item()) * max(1, grad_accum_steps))
if step % 250 == 0:
print(
f" epoch={epoch} step={step}/{len(train_loader)} "
f"loss={float(np.mean(train_losses[-50:])):.4f}",
flush=True,
)
if len(train_loader) % max(1, grad_accum_steps) != 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
val_metrics, _rows, _logits, _labels = evaluate(
model=model,
loader=val_loader,
samples=[],
method=method,
device=device,
criterion=criterion,
num_classes=num_classes,
dataset_key="val",
seed=0,
)
val_score = val_metrics["macro_f1"]
history_row = {
"epoch": epoch,
"train_loss": float(np.mean(train_losses)) if train_losses else float("nan"),
"val_macro_f1": val_metrics["macro_f1"],
"val_weighted_f1": val_metrics["weighted_f1"],
"val_accuracy": val_metrics["accuracy"],
"val_raw_ece": val_metrics["raw_ece"],
"elapsed_seconds": time.time() - start,
}
history.append(history_row)
print(
f"epoch={epoch} train_loss={history_row['train_loss']:.4f} "
f"val_mF1={val_metrics['macro_f1']:.4f} val_ECE={val_metrics['raw_ece']:.4f}",
flush=True,
)
if val_score > best_val:
best_val = val_score
best_epoch = epoch
best_state = trainable_state_dict()
bad_epochs = 0
else:
bad_epochs += 1
if bad_epochs >= patience:
break
return {"best_epoch": best_epoch, "best_val_macro_f1": best_val}, best_state, history
def run_single(args: argparse.Namespace, dataset_key: str, method_key: str, seed: int) -> Optional[Dict[str, Any]]:
method = METHODS[method_key]
out_root = Path(args.output_root)
run_dir = out_root / "runs" / dataset_key / method.key / f"seed_{seed}"
metrics_path = run_dir / "metrics.json"
pred_path = out_root / "predictions" / dataset_key / f"{method.key}_seed_{seed}.csv"
history_path = run_dir / "history.csv"
ckpt_path = run_dir / "checkpoint.pt"
if metrics_path.exists() and pred_path.exists() and not args.overwrite:
print(f"SKIP completed {dataset_key} {method.key} seed={seed}", flush=True)
return json.loads(metrics_path.read_text(encoding="utf-8"))
print(f"RUN dataset={dataset_key} method={method.key} seed={seed}", flush=True)
if args.dry_run:
return None
set_seed(seed)
device = torch.device(args.device if torch.cuda.is_available() or args.device == "cpu" else "cpu")
(
model_cls,
cfg,
train_loader,
val_loader,
test_loader,
train_samples,
val_samples,
test_samples,
num_classes,
label_names,
) = load_dataset(
dataset_key=dataset_key,
seed=seed,
batch_size=args.batch_size,
max_length=args.max_length,
num_workers=args.num_workers,
method=method,
hfm_deleak_manifest=args.hfm_deleak_manifest,
limits=(args.limit_train_samples, args.limit_val_samples, args.limit_test_samples),
)
cfg.update(
{
"architecture": method.architecture,
"enable_clip_lora": method.enable_lora,
"enable_text_lora": method.enable_lora,
"seed": seed,
"batch_size": args.batch_size,
"max_length": args.max_length,
}
)
model = model_cls(cfg).to(device)
if hasattr(model, "vision_lora"):
freeze_non_lora(model.vision_lora)
if hasattr(model, "text"):
freeze_non_lora(model.text)
apply_method_trainability(model, method)
stats = model.parameter_stats()
train_info, best_state, history = train_one(
model=model,
train_loader=train_loader,
val_loader=val_loader,
method=method,
device=device,
epochs=args.epochs,
patience=args.patience,
grad_accum_steps=args.grad_accum_steps,
learning_rate=args.learning_rate,
weight_decay=args.weight_decay,
num_classes=num_classes,
)
if best_state:
model.load_state_dict(best_state, strict=False)
criterion = nn.CrossEntropyLoss()
test_metrics, prediction_rows, _logits, _labels = evaluate(
model=model,
loader=test_loader,
samples=test_samples,
method=method,
device=device,
criterion=criterion,
num_classes=num_classes,
dataset_key=dataset_key,
seed=seed,
)
run_dir.mkdir(parents=True, exist_ok=True)
torch.save(
{
"model_state": {
key: value.detach().cpu()
for key, value in model.state_dict().items()
if key in {name for name, parameter in model.named_parameters() if parameter.requires_grad}
},
"checkpoint_type": "trainable_parameters_only",
"cfg": cfg,
"dataset": dataset_key,
"method": method.key,
"seed": seed,
"metrics": test_metrics,
"train_info": train_info,
},
ckpt_path,
)
write_csv(history_path, history, ["epoch", "train_loss", "val_macro_f1", "val_weighted_f1", "val_accuracy", "val_raw_ece", "elapsed_seconds"])
pred_fields = [
"sample_id",
"dataset",
"split",
"seed",
"method",
"true_label",
"predicted_label",
]
for c in range(num_classes):
pred_fields.extend(
[
f"logit_class_{c}",
f"prob_class_{c}",
f"visual_prob_class_{c}",
f"text_prob_class_{c}",
f"disagreement_class_{c}",
]
)
pred_fields.extend(
[
"disagreement_l1",
"prediction_entropy",
"max_confidence",
"primary_prediction",
"primary_confidence",
"refined_prediction",
"refined_confidence",
"correct_primary",
"correct_refined",
"number_of_images",
]
)
write_csv(pred_path, prediction_rows, pred_fields)
metric_payload: Dict[str, Any] = {
"dataset": dataset_key,
"method": method.key,
"method_display": method.display,
"configuration": method.train_target,
"disagreement_formulation": method.disagreement_formulation,
"additional_loss": method.additional_loss,
"lambda_primary": method.lambda_primary,
"lambda_unimodal": method.lambda_unimodal,
"consistency_beta": method.consistency_beta if method.additional_loss == "js_consistency" else None,
"infonce_alpha": method.infonce_alpha if method.additional_loss == "infonce_alignment" else None,
"infonce_temperature": method.infonce_temperature if method.additional_loss == "infonce_alignment" else None,
"seed": seed,
"num_classes": num_classes,
"label_names": label_names,
"train_samples": len(train_samples),
"val_samples": len(val_samples),
"test_samples": len(test_samples),
"total_params": stats["total"],
"trainable_params": stats["trainable"],
"best_epoch": train_info["best_epoch"],
**test_metrics,
}
metrics_path.write_text(json.dumps(metric_payload, indent=2), encoding="utf-8")
print(
f"DONE {dataset_key} {method.key} seed={seed} "
f"mF1={test_metrics['macro_f1']:.4f} wF1={test_metrics['weighted_f1']:.4f} "
f"ECE={test_metrics['raw_ece']:.4f}",
flush=True,
)
return metric_payload
def aggregate(output_root: Path) -> None:
metric_rows: List[Dict[str, Any]] = []
for path in sorted((output_root / "runs").glob("*/*/seed_*/metrics.json")):
try:
metric_rows.append(json.loads(path.read_text(encoding="utf-8")))
except Exception:
continue
fields = [
"dataset",
"method",
"method_display",
"configuration",
"seed",
"accuracy",
"macro_precision",
"macro_recall",
"macro_f1",
"weighted_f1",
"auroc",
"raw_ece",
"adaptive_ece",
"nll",
"brier",
"aurc",
"risk_at_80_coverage",
"risk_at_90_coverage",
"accuracy_at_80_coverage",
"accuracy_at_90_coverage",
"total_params",
"trainable_params",
"best_epoch",
"train_samples",
"val_samples",
"test_samples",
]
write_csv(output_root / "aggregate_results" / "main_results.csv", metric_rows, fields)
grouped: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
for row in metric_rows:
grouped.setdefault((row["dataset"], row["method"]), []).append(row)
summary_rows: List[Dict[str, Any]] = []
for (dataset, method), rows in sorted(grouped.items()):
out: Dict[str, Any] = {"dataset": dataset, "method": method, "runs": len(rows)}
for metric in ["accuracy", "macro_f1", "weighted_f1", "raw_ece", "nll", "brier", "aurc"]:
vals = np.array([float(row[metric]) for row in rows if row.get(metric) is not None], dtype=float)
if vals.size:
out[f"{metric}_mean"] = float(vals.mean())
out[f"{metric}_std"] = float(vals.std(ddof=1)) if vals.size > 1 else 0.0
summary_rows.append(out)
write_csv(
output_root / "aggregate_results" / "summary_by_method.csv",
summary_rows,
[
"dataset",
"method",
"runs",
"accuracy_mean",
"accuracy_std",
"macro_f1_mean",
"macro_f1_std",
"weighted_f1_mean",
"weighted_f1_std",
"raw_ece_mean",
"raw_ece_std",
"nll_mean",
"nll_std",
"brier_mean",
"brier_std",
"aurc_mean",
"aurc_std",
],
)
def planned_runs(args: argparse.Namespace) -> List[Tuple[str, str, int]]:
runs: List[Tuple[str, str, int]] = []
if args.methods:
method_keys = args.methods
elif args.stage == "stage_b":
method_keys = STAGE_B_METHODS
elif args.stage == "stage_c":
method_keys = STAGE_C_METHODS
else:
method_keys = sorted(set(STAGE_B_METHODS + STAGE_C_METHODS), key=(STAGE_B_METHODS + STAGE_C_METHODS).index)
if args.stage == "stage_b":
seeds = [args.stage_b_seed]
else:
seeds = args.seeds
for dataset in args.datasets:
for seed in seeds:
for method in method_keys:
runs.append((dataset, method, seed))
return runs
def main() -> None:
args = parse_args()
output_root = Path(args.output_root)
output_root.mkdir(parents=True, exist_ok=True)
plan = planned_runs(args)
print(f"Planned runs: {len(plan)}", flush=True)
for dataset_key, method_key, seed in plan:
run_single(args, dataset_key, method_key, seed)
aggregate(output_root)
aggregate(output_root)
print(f"Wrote aggregate results to {output_root / 'aggregate_results'}", flush=True)
if __name__ == "__main__":
main()