"""BoccaccioAI - Main training entrypoint. Invoked as ``python -m src.training.train`` from the project root. Supports both pre-training (on binary memmap data) and instruction fine-tuning (on JSONL data). De Lauretis Tech """ from __future__ import annotations import argparse import json import logging from pathlib import Path import torch import yaml import lightning as L from lightning.pytorch.callbacks import ModelCheckpoint, LearningRateMonitor from lightning.pytorch.loggers import WandbLogger, TensorBoardLogger from torch.utils.data import DataLoader from src.model.config import BoccaccioConfig from src.training.lightning_module import BoccaccioLightningModule from src.training.callbacks import TrainingStatsCallback, SaveConfigCallback from src.data.dataset import PreTokenizedDataset, InstructionDataset log = logging.getLogger(__name__) # ------------------------------------------------------------------ # CLI # ------------------------------------------------------------------ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="BoccaccioAI training entrypoint") parser.add_argument( "--mode", type=str, required=True, choices=["pretrain", "finetune"], help="Training mode: 'pretrain' or 'finetune'.", ) parser.add_argument( "--model-config", type=str, default="configs/model.yaml", help="Path to model architecture YAML.", ) parser.add_argument( "--model-variant", type=str, default="model", choices=["model", "nano"], help="Model variant to load from the config.", ) parser.add_argument( "--training-config", type=str, default="configs/training.yaml", help="Path to training hyper-parameters YAML.", ) parser.add_argument( "--data-path", type=str, required=True, help="Path to the tokenized data directory.", ) parser.add_argument( "--tokenizer-path", type=str, default="tokenizer/boccaccio-32k.json", help="Path to the tokenizer JSON file.", ) parser.add_argument( "--resume-from", type=str, default=None, help="Path to a Lightning checkpoint to load weights from (resets step/optimizer).", ) parser.add_argument( "--resume-ckpt", type=str, default=None, help="Path to a Lightning checkpoint for full resume (step, optimizer, LR, etc.).", ) parser.add_argument( "--wandb-project", type=str, default="boccaccio-ai", help="Weights & Biases project name.", ) parser.add_argument( "--config-section", type=str, default=None, help="Override config section name (e.g. 'pretrain_phase2'). Defaults to --mode value.", ) parser.add_argument( "--wandb-offline", action="store_true", help="Run Weights & Biases in offline mode.", ) parser.add_argument( "--seed", type=int, default=42, help="Random seed for reproducibility.", ) return parser.parse_args() # ------------------------------------------------------------------ # Dataset / DataLoader helpers # ------------------------------------------------------------------ def _build_dataloaders( mode: str, data_path: str, tokenizer_path: str, train_config: dict, ) -> tuple[DataLoader, DataLoader | None]: """Create train and (optional) validation DataLoaders.""" sequence_length: int = train_config["sequence_length"] batch_size: int = train_config["micro_batch_size"] num_workers: int = train_config.get("num_workers", 4) if mode == "pretrain": train_file = str(Path(data_path) / "train.bin") val_file = str(Path(data_path) / "val.bin") train_dataset = PreTokenizedDataset(train_file, sequence_length) val_dataset = PreTokenizedDataset(val_file, sequence_length) log.info( "Pre-training datasets -- train: %d sequences, val: %d sequences", len(train_dataset), len(val_dataset), ) else: # Fine-tuning: usa InstructionDataset con formato JSONL conversations. # Loss mascherata (-100) su user/system, solo assistant + contribuiscono. train_file = Path(data_path) / "train.jsonl" val_file = Path(data_path) / "val.jsonl" if not train_file.exists(): raise FileNotFoundError( f"Fine-tuning requires train.jsonl in {data_path}. " f"Run 'python -m src.data.prepare_finetune' first." ) train_dataset = InstructionDataset(str(train_file), tokenizer_path, sequence_length) if val_file.exists(): val_dataset = InstructionDataset(str(val_file), tokenizer_path, sequence_length) log.info( "Fine-tuning datasets (JSONL) -- train: %d examples, val: %d examples", len(train_dataset), len(val_dataset), ) else: val_dataset = None log.info( "Fine-tuning dataset (JSONL) -- train: %d examples (no validation set)", len(train_dataset), ) train_loader = DataLoader( train_dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True, ) val_loader = None if val_dataset is not None: val_loader = DataLoader( val_dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True, ) return train_loader, val_loader # ------------------------------------------------------------------ # Callbacks # ------------------------------------------------------------------ def _build_callbacks( train_config: dict, model_config: BoccaccioConfig, num_params: int, ) -> list[L.Callback]: """Assemble the list of Lightning callbacks.""" checkpoint_dir = train_config.get("checkpoint_dir", "checkpoints") checkpoint_every = train_config.get("checkpoint_every_n_steps", 1000) save_top_k = train_config.get("save_top_k", 3) callbacks: list[L.Callback] = [ ModelCheckpoint( dirpath=checkpoint_dir, every_n_train_steps=checkpoint_every, save_top_k=-1, save_last=True, ), LearningRateMonitor(logging_interval="step"), TrainingStatsCallback( num_params=num_params, sequence_length=train_config["sequence_length"], gradient_accumulation_steps=train_config.get("gradient_accumulation_steps", 1), ), SaveConfigCallback(model_config, train_config), ] return callbacks # ------------------------------------------------------------------ # Logger # ------------------------------------------------------------------ def _build_logger( wandb_project: str, wandb_offline: bool, mode: str, ) -> WandbLogger | TensorBoardLogger: """Try to create a WandB logger; fall back to TensorBoard on failure.""" try: logger = WandbLogger( project=wandb_project, name=mode, offline=wandb_offline, ) log.info("Using Weights & Biases logger (project=%s)", wandb_project) return logger except Exception: log.warning( "Failed to initialise WandB logger; falling back to TensorBoard." ) return TensorBoardLogger(save_dir="logs", name=mode) # ------------------------------------------------------------------ # Main # ------------------------------------------------------------------ def main() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", ) args = parse_args() if args.resume_from and args.resume_ckpt: raise ValueError( "Cannot use both --resume-from and --resume-ckpt. " "Use --resume-from to load weights only (resets step), " "or --resume-ckpt for full resume (step, optimizer, LR)." ) # Seed everything for reproducibility. L.seed_everything(args.seed) # ── Load configs ────────────────────────────────────────────── model_config = BoccaccioConfig.from_yaml(args.model_config, args.model_variant) log.info("Model config loaded: %s (variant=%s)", args.model_config, args.model_variant) with open(args.training_config, "r", encoding="utf-8") as f: full_training_config = yaml.safe_load(f) config_section = args.config_section or args.mode train_config: dict = full_training_config[config_section] log.info("Training config loaded: %s [%s]", args.training_config, config_section) # ── Datasets & DataLoaders ──────────────────────────────────── train_loader, val_loader = _build_dataloaders( mode=args.mode, data_path=args.data_path, tokenizer_path=args.tokenizer_path, train_config=train_config, ) # Inject num_examples into train_config for the scheduler calculation. # This is needed for fine-tuning where total_steps depends on the number # of examples (not num_tokens, since examples are padded to sequence_length). if args.mode == "finetune": train_config["num_examples"] = len(train_loader.dataset) log.info("Fine-tuning: %d examples in training set", train_config["num_examples"]) # ── Lightning module ────────────────────────────────────────── model = BoccaccioLightningModule(model_config, train_config) num_params = sum(p.numel() for p in model.parameters() if p.requires_grad) # ── Load pre-trained weights (for fine-tuning) ─────────────── # Lightning's ckpt_path mechanism doesn't handle torch.compile prefix # remapping (model._orig_mod. -> model.), so we load weights manually. ckpt_path = None if args.resume_from: log.info("Loading pre-trained weights from %s", args.resume_from) ckpt = torch.load(args.resume_from, map_location="cpu", weights_only=False) state_dict = ckpt.get("state_dict", ckpt) # Remap torch.compile prefix: model._orig_mod.X -> model.model.X # The checkpoint was saved with torch.compile which wraps the model # as model._orig_mod.. The LightningModule has structure: # self.model = BoccaccioForCausalLM (model.) # BoccaccioForCausalLM.model = Transformer (model.model.) # So checkpoint key "model._orig_mod.model.embed_tokens" must become # "model.model.embed_tokens". cleaned = {} for key, value in state_dict.items(): new_key = key if new_key.startswith("model._orig_mod."): # Strip "model._orig_mod." and prepend "model." new_key = "model." + new_key[len("model._orig_mod."):] elif new_key.startswith("_orig_mod."): new_key = new_key[len("_orig_mod."):] cleaned[new_key] = value missing, unexpected = model.load_state_dict(cleaned, strict=False) if missing: log.warning("Missing keys (%d): %s", len(missing), missing[:5]) if unexpected: log.warning("Unexpected keys (%d): %s", len(unexpected), unexpected[:5]) log.info("Pre-trained weights loaded successfully.") # ── Callbacks ───────────────────────────────────────────────── callbacks = _build_callbacks(train_config, model_config, num_params) # ── Logger ──────────────────────────────────────────────────── logger = _build_logger(args.wandb_project, args.wandb_offline, args.mode) # ── Compute max_steps (pretrain) or max_epochs (finetune) ───── trainer_kwargs: dict = {} if args.mode == "pretrain": effective_batch_tokens = ( train_config["micro_batch_size"] * train_config["gradient_accumulation_steps"] * train_config["sequence_length"] ) max_steps = int(train_config["num_tokens"]) // effective_batch_tokens trainer_kwargs["max_steps"] = max_steps log.info( "Pre-training for %d steps (~%d tokens, effective batch = %d tokens/step)", max_steps, int(train_config["num_tokens"]), effective_batch_tokens, ) else: max_epochs = train_config.get("num_epochs", 3) trainer_kwargs["max_epochs"] = max_epochs log.info("Fine-tuning for %d epoch(s)", max_epochs) # ── Trainer ─────────────────────────────────────────────────── trainer = L.Trainer( accelerator=train_config.get("accelerator", "auto"), devices=train_config.get("devices", "auto"), strategy=train_config.get("strategy", "auto"), precision=train_config.get("precision", "bf16-mixed"), accumulate_grad_batches=train_config.get("gradient_accumulation_steps", 1), gradient_clip_val=train_config.get("gradient_clip_val", 1.0), val_check_interval=train_config.get("val_check_interval", None), log_every_n_steps=train_config.get("log_every_n_steps", 50), callbacks=callbacks, logger=logger, enable_checkpointing=True, **trainer_kwargs, ) # ── Train ───────────────────────────────────────────────────── # --resume-ckpt: full resume (step, optimizer, LR schedule, etc.) # --resume-from: load weights only (step resets to 0) ckpt_path = args.resume_ckpt if args.resume_ckpt else None trainer.fit(model, train_loader, val_loader, ckpt_path=ckpt_path) # ── Save final model weights ────────────────────────────────── checkpoint_dir = Path(train_config.get("checkpoint_dir", "checkpoints")) checkpoint_dir.mkdir(parents=True, exist_ok=True) final_path = checkpoint_dir / "model.pt" state_dict = model.model.state_dict() torch.save(state_dict, final_path) log.info("Final model weights saved to %s", final_path) log.info("Training complete.") if __name__ == "__main__": main()