|
|
| """
|
| train.py β Entry point for CAFF training.
|
|
|
| Usage
|
| -----
|
| python train.py --config configs/caff_full.yaml --seed 42
|
| python train.py --config configs/caff_full.yaml --seed 1337
|
| python train.py --config configs/caff_no_hc3.yaml --seed 42
|
|
|
| Outputs
|
| -------
|
| runs/<config_name>/seed_<seed>/
|
| βββ config.json # frozen config + content hash
|
| βββ train.log # full log
|
| βββ history.jsonl # per-epoch metrics
|
| βββ epoch_001.pt ... best.pt # checkpoints
|
| βββ final_metrics.json # test-set metrics
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import argparse
|
| import json
|
| import logging
|
| import platform
|
| from dataclasses import asdict
|
| from pathlib import Path
|
|
|
| import torch
|
| import yaml
|
|
|
| from caff import (
|
| AblationFlags,
|
| CAFFConfig,
|
| CAFFEvaluator,
|
| CAFFModel,
|
| CAFFTrainer,
|
| CAFFTripleDataset,
|
| CachedBFSExtractor,
|
| FrozenBioEncoder,
|
| KnowledgeGraph,
|
| RelationEmbeddingCache,
|
| load_qa_split,
|
| )
|
| from caff.utils import set_global_seed
|
| from caff.utils.logging import setup_logging
|
|
|
| logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def parse_args() -> argparse.Namespace:
|
| p = argparse.ArgumentParser(description="Train CAFF.")
|
| p.add_argument("--config", required=True, help="Path to YAML config.")
|
| p.add_argument("--seed", type=int, default=None,
|
| help="Override config.seed.")
|
| p.add_argument("--output-root", default="runs",
|
| help="Root directory for run artifacts.")
|
| p.add_argument("--cache-dir", default="cache",
|
| help="Cache directory (BFS subgraphs, relation embeds).")
|
| p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| p.add_argument("--debug-subset", type=int, default=None,
|
| help="If set, restrict to first N training queries (smoke test).")
|
| return p.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def detect_hardware_overrides(device: str) -> dict[str, int | str]:
|
| """Choose (micro_batch_size, grad_accum_steps, mixed_precision)
|
| to preserve effective batch=256 (paper Β§8.4) on the available GPU.
|
|
|
| Returns a dict the YAML loader will use to override config defaults.
|
| """
|
| if device != "cuda":
|
| return {
|
| "micro_batch_size": 8,
|
| "grad_accum_steps": 32,
|
| "mixed_precision": "no",
|
| }
|
|
|
| name = torch.cuda.get_device_name(0).lower()
|
| vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
|
|
|
| if vram_gb >= 75:
|
| overrides = {"micro_batch_size": 256, "grad_accum_steps": 1,
|
| "mixed_precision": "bf16"}
|
| elif vram_gb >= 35:
|
| overrides = {"micro_batch_size": 64, "grad_accum_steps": 4,
|
| "mixed_precision": "bf16"}
|
| elif vram_gb >= 22:
|
| overrides = {"micro_batch_size": 32, "grad_accum_steps": 8,
|
| "mixed_precision": "bf16"}
|
| elif vram_gb >= 14:
|
| overrides = {"micro_batch_size": 8, "grad_accum_steps": 32,
|
| "mixed_precision": "fp16"}
|
| else:
|
| overrides = {"micro_batch_size": 4, "grad_accum_steps": 64,
|
| "mixed_precision": "fp16"}
|
|
|
| logger.info(
|
| f"Hardware: {torch.cuda.get_device_name(0)} ({vram_gb:.0f}GB) "
|
| f"β micro={overrides['micro_batch_size']} "
|
| f"accum={overrides['grad_accum_steps']} "
|
| f"precision={overrides['mixed_precision']} "
|
| f"(effective batch = 256)"
|
| )
|
| return overrides
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def load_config(
|
| yaml_path: str | Path,
|
| seed_override: int | None,
|
| hw_overrides: dict,
|
| ) -> tuple[CAFFConfig, AblationFlags]:
|
| """Load YAML + apply hardware + seed overrides."""
|
| yaml_path = Path(yaml_path)
|
| with yaml_path.open("r", encoding="utf-8") as f:
|
| raw = yaml.safe_load(f)
|
|
|
| cfg_dict = raw.get("config", {})
|
| abl_dict = raw.get("ablation", {})
|
|
|
|
|
| cfg_dict.update(hw_overrides)
|
| if seed_override is not None:
|
| cfg_dict["seed"] = seed_override
|
|
|
| config = CAFFConfig(**cfg_dict)
|
| ablation = AblationFlags(**abl_dict) if abl_dict else AblationFlags()
|
| return config, ablation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def make_run_dir(
|
| output_root: str | Path,
|
| config_path: str | Path,
|
| seed: int,
|
| ) -> Path:
|
| """runs/<config-stem>/seed_<seed>/"""
|
| config_stem = Path(config_path).stem
|
| run_dir = Path(output_root) / config_stem / f"seed_{seed}"
|
| run_dir.mkdir(parents=True, exist_ok=True)
|
| return run_dir
|
|
|
|
|
| def write_run_manifest(
|
| run_dir: Path,
|
| config: CAFFConfig,
|
| ablation: AblationFlags,
|
| args: argparse.Namespace,
|
| ) -> None:
|
| """Write a frozen reproducibility manifest."""
|
| manifest = {
|
| "config": asdict(config),
|
| "config_hash": config.hash(),
|
| "ablation": asdict(ablation),
|
| "args": vars(args),
|
| "torch_version": torch.__version__,
|
| "cuda_available": torch.cuda.is_available(),
|
| "gpu_name": (torch.cuda.get_device_name(0)
|
| if torch.cuda.is_available() else None),
|
| "python_version": platform.python_version(),
|
| "platform": platform.platform(),
|
| }
|
| with (run_dir / "config.json").open("w", encoding="utf-8") as f:
|
| json.dump(manifest, f, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def main() -> None:
|
| args = parse_args()
|
| hw_overrides = detect_hardware_overrides(args.device)
|
| config, ablation = load_config(args.config, args.seed, hw_overrides)
|
|
|
|
|
| run_dir = make_run_dir(args.output_root, args.config, config.seed)
|
| setup_logging(level="INFO", log_file=run_dir / "train.log")
|
|
|
| logger.info("=" * 70)
|
| logger.info("CAFF Training")
|
| logger.info(f" Config: {args.config}")
|
| logger.info(f" Seed: {config.seed}")
|
| logger.info(f" Run dir: {run_dir}")
|
| logger.info(f" Hash: {config.hash()}")
|
| logger.info("=" * 70)
|
|
|
| write_run_manifest(run_dir, config, ablation, args)
|
|
|
|
|
| set_global_seed(config.seed, deterministic=config.deterministic)
|
|
|
|
|
| logger.info(f"Loading KG from {config.kg_path}...")
|
| kg = KnowledgeGraph.from_tsv(config.kg_path, min_relation_freq=config.min_relation_freq)
|
|
|
|
|
| logger.info(f"Loading frozen encoder: {config.encoder_name}")
|
| encoder = FrozenBioEncoder(config.encoder_name, device=args.device)
|
| rel_cache_path = Path(args.cache_dir) / "relation_embeddings.pt"
|
| relation_cache = RelationEmbeddingCache(
|
| encoder=encoder,
|
| relations=kg.relations,
|
| cache_path=rel_cache_path,
|
| )
|
|
|
|
|
| bfs_dir = Path(args.cache_dir) / "bfs"
|
| bfs = CachedBFSExtractor(kg, L=config.L, K_r=config.K_r, cache_dir=bfs_dir)
|
|
|
|
|
| logger.info("Building train/dev datasets...")
|
| train_recs = load_qa_split(config.train_path)
|
| dev_recs = load_qa_split(config.dev_path)
|
| if args.debug_subset:
|
| train_recs = train_recs[: args.debug_subset]
|
| dev_recs = dev_recs[: max(1, args.debug_subset // 5)]
|
| logger.warning(f"DEBUG subset: train={len(train_recs)} dev={len(dev_recs)}")
|
|
|
| train_ds = CAFFTripleDataset(train_recs, bfs, require_gold=True)
|
| dev_ds = CAFFTripleDataset(dev_recs, bfs, require_gold=True)
|
|
|
|
|
| model = CAFFModel(config, relation_cache, ablation=ablation).to(args.device)
|
|
|
|
|
| evaluator = CAFFEvaluator(
|
| config=config,
|
| encoder=encoder,
|
| mode="teacher_forced",
|
| threshold=config.theta,
|
| )
|
|
|
|
|
| lam_C = 0.0 if not ablation.use_hc3 else config.lambda_C
|
| lam_D = 0.0 if not ablation.use_dc else config.lambda_D
|
|
|
|
|
| trainer = CAFFTrainer(
|
| config=config,
|
| model=model,
|
| encoder=encoder,
|
| train_dataset=train_ds,
|
| dev_dataset=dev_ds,
|
| evaluator=evaluator,
|
| ckpt_dir=run_dir,
|
| ablation_lambda_C=lam_C,
|
| ablation_lambda_D=lam_D,
|
| log_jsonl_path=run_dir / "history.jsonl",
|
| )
|
|
|
| history = trainer.train()
|
|
|
|
|
| best = history.best_epoch("dev_f1")
|
| if best is not None:
|
| logger.info(f"Best epoch: {best.epoch} dev_f1={best.dev_f1:.4f}")
|
| with (run_dir / "final_metrics.json").open("w", encoding="utf-8") as f:
|
| json.dump(asdict(best), f, indent=2)
|
|
|
| logger.info("Training complete.")
|
|
|
|
|
| if __name__ == "__main__":
|
| main() |