CoolFace
Apppublic

S-Dreamer/CodeCraftLab

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
pipeline.py465 linesDownload Raw Back to root
1"""2Fine-tuning pipeline with structured logging and eval hooks.3 4Pipeline stages:5  1. Preflight validation  — config, GPU, disk, token6  2. Dataset preparation   — load, tokenize, split7  3. Model initialisation  — base model + LoRA adapters8  4. Training              — Trainer with custom callbacks9  5. Evaluation            — post-training metric suite10  6. Checkpoint export     — save + optional HF Hub push11 12Each stage emits structured log events. Eval hooks are composable and13run both during training (via TrainerCallback) and post-training.14"""15 16from __future__ import annotations17 18import json19import os20import shutil21import time22from dataclasses import dataclass, field23from pathlib import Path24from typing import Any25 26import structlog27import torch28from datasets import Dataset, DatasetDict, load_dataset29from peft import LoraConfig, TaskType, get_peft_model30from transformers import (31    AutoModelForCausalLM,32    AutoTokenizer,33    DataCollatorForLanguageModeling,34    PreTrainedModel,35    PreTrainedTokenizerBase,36    Trainer,37    TrainerCallback,38    TrainerControl,39    TrainerState,40    TrainingArguments,41)42 43from training.config import EvalMetric, EvalStrategy, TrainingJobConfig44from training.evaluators import (45    BleuEvaluator,46    ExecutionAccuracyEvaluator,47    ExactMatchEvaluator,48    PassAtKEvaluator,49)50 51log = structlog.get_logger(__name__)52 53 54# ---------------------------------------------------------------------------55# Eval result container56# ---------------------------------------------------------------------------57@dataclass58class EvalResults:59    job_name: str60    epoch: float61    step: int62    metrics: dict[str, float] = field(default_factory=dict)63    errors: list[str] = field(default_factory=list)64    duration_seconds: float = 0.065 66    def log(self, bound_log: structlog.BoundLogger) -> None:67        bound_log.info(68            "eval.completed",69            epoch=self.epoch,70            step=self.step,71            duration_seconds=round(self.duration_seconds, 2),72            **self.metrics,73        )74        for error in self.errors:75            bound_log.warning("eval.error", message=error)76 77    def to_dict(self) -> dict[str, Any]:78        return {79            "job_name": self.job_name,80            "epoch": self.epoch,81            "step": self.step,82            "metrics": self.metrics,83            "errors": self.errors,84            "duration_seconds": self.duration_seconds,85        }86 87 88# ---------------------------------------------------------------------------89# Eval hook registry90# ---------------------------------------------------------------------------91class EvalHookRunner:92    """93    Runs the configured evaluation metrics against a model + dataset.94 95    Evaluators are resolved from the job config at construction time.96    Each evaluator is independent; failures in one do not abort others.97    """98 99    def __init__(self, config: TrainingJobConfig, tokenizer: PreTrainedTokenizerBase) -> None:100        self._config = config101        self._tokenizer = tokenizer102        self._evaluators = self._build_evaluators()103        self._log = log.bind(job=config.job_name)104 105    def _build_evaluators(self) -> dict[EvalMetric, Any]:106        evals: dict[EvalMetric, Any] = {}107        eval_cfg = self._config.evaluation108        for metric in eval_cfg.metrics:109            match metric:110                case EvalMetric.PASS_AT_1:111                    evals[metric] = PassAtKEvaluator(k=1, n=eval_cfg.num_samples_per_problem)112                case EvalMetric.PASS_AT_10:113                    evals[metric] = PassAtKEvaluator(k=10, n=eval_cfg.num_samples_per_problem)114                case EvalMetric.BLEU:115                    evals[metric] = BleuEvaluator()116                case EvalMetric.EXECUTION_ACCURACY:117                    evals[metric] = ExecutionAccuracyEvaluator(118                        timeout=self._config.evaluation.timeout_seconds119                    )120                case EvalMetric.EXACT_MATCH:121                    evals[metric] = ExactMatchEvaluator()122        return evals123 124    def run(125        self,126        model: PreTrainedModel,127        eval_dataset: Dataset,128        epoch: float,129        step: int,130    ) -> EvalResults:131        start = time.perf_counter()132        results = EvalResults(job_name=self._config.job_name, epoch=epoch, step=step)133 134        model.eval()135        with torch.no_grad():136            for metric, evaluator in self._evaluators.items():137                try:138                    score = evaluator.evaluate(139                        model=model,140                        tokenizer=self._tokenizer,141                        dataset=eval_dataset,142                    )143                    results.metrics[metric.value] = round(score, 4)144                    self._log.info("eval.metric", metric=metric.value, score=score)145                except Exception as exc:  # noqa: BLE001146                    msg = f"{metric.value}: {exc}"147                    results.errors.append(msg)148                    self._log.warning("eval.metric_failed", metric=metric.value, error=str(exc))149 150        results.duration_seconds = time.perf_counter() - start151        results.log(self._log)152        return results153 154 155# ---------------------------------------------------------------------------156# Custom training callback157# ---------------------------------------------------------------------------158class CodeCraftLabCallback(TrainerCallback):159    """160    Injects structured logging and eval hooks into the HF Trainer loop.161    """162 163    def __init__(164        self,165        hook_runner: EvalHookRunner,166        eval_dataset: Dataset,167        results_path: Path,168    ) -> None:169        self._runner = hook_runner170        self._eval_dataset = eval_dataset171        self._results_path = results_path172        self._all_results: list[dict[str, Any]] = []173        self._log = log174 175    def on_epoch_end(176        self,177        args: TrainingArguments,178        state: TrainerState,179        control: TrainerControl,180        model: PreTrainedModel,181        **kwargs: Any,182    ) -> TrainerControl:183        self._log.info(184            "training.epoch_end",185            epoch=state.epoch,186            step=state.global_step,187            loss=state.log_history[-1].get("loss") if state.log_history else None,188        )189        results = self._runner.run(190            model=model,191            eval_dataset=self._eval_dataset,192            epoch=state.epoch or 0.0,193            step=state.global_step,194        )195        self._all_results.append(results.to_dict())196        self._persist_results()197        return control198 199    def on_log(200        self,201        args: TrainingArguments,202        state: TrainerState,203        control: TrainerControl,204        logs: dict[str, float],205        **kwargs: Any,206    ) -> TrainerControl:207        self._log.info("training.log", step=state.global_step, **logs)208        return control209 210    def on_train_end(211        self,212        args: TrainingArguments,213        state: TrainerState,214        control: TrainerControl,215        **kwargs: Any,216    ) -> TrainerControl:217        self._log.info(218            "training.completed",219            total_steps=state.global_step,220            total_flos=state.total_flos,221        )222        return control223 224    def _persist_results(self) -> None:225        self._results_path.write_text(226            json.dumps(self._all_results, indent=2), encoding="utf-8"227        )228 229 230# ---------------------------------------------------------------------------231# Pipeline232# ---------------------------------------------------------------------------233class FineTuningPipeline:234    """235    Orchestrates the full fine-tuning lifecycle.236 237    Usage:238        config = TrainingJobConfig.model_validate(raw_dict)239        pipeline = FineTuningPipeline(config)240        pipeline.run()241    """242 243    def __init__(self, config: TrainingJobConfig) -> None:244        self._config = config245        self._log = log.bind(job=config.job_name, model=config.base_model)246        self._output_dir = Path(config.checkpoint.output_dir) / config.job_name247 248    # ------------------------------------------------------------------249    # Public entry point250    # ------------------------------------------------------------------251    def run(self) -> Path:252        """Execute all pipeline stages. Returns the final checkpoint path."""253        self._log.info("pipeline.started")254        self._preflight()255        datasets = self._prepare_datasets()256        model, tokenizer = self._load_model()257        self._train(model, tokenizer, datasets)258        final_path = self._export(model, tokenizer)259        self._log.info("pipeline.finished", output=str(final_path))260        return final_path261 262    # ------------------------------------------------------------------263    # Stage 1: Preflight264    # ------------------------------------------------------------------265    def _preflight(self) -> None:266        self._log.info("pipeline.preflight")267 268        # Validate config (already done at submission, but be defensive)269        self._config.model_validate(self._config.model_dump())270 271        # GPU check272        if torch.cuda.is_available():273            device_name = torch.cuda.get_device_name(0)274            vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9275            self._log.info("preflight.gpu", device=device_name, vram_gb=round(vram_gb, 1))276        else:277            self._log.warning("preflight.no_gpu", message="Training on CPU — will be slow")278 279        # Disk space (rough check — 20 GB minimum)280        free_gb = shutil.disk_usage(self._output_dir.parent).free / 1e9281        if free_gb < 20:282            self._log.warning("preflight.disk_low", free_gb=round(free_gb, 1))283 284        # HF token if pushing285        if self._config.hub.push_to_hub and not os.environ.get("HF_TOKEN"):286            raise EnvironmentError("HF_TOKEN is required when hub.push_to_hub=true")287 288        self._output_dir.mkdir(parents=True, exist_ok=True)289        self._log.info("preflight.passed")290 291    # ------------------------------------------------------------------292    # Stage 2: Dataset preparation293    # ------------------------------------------------------------------294    def _prepare_datasets(self) -> DatasetDict:295        self._log.info("pipeline.dataset_prep")296        ds_cfg = self._config.dataset297 298        # Load — support both HF Hub paths and internal dataset IDs299        raw: Dataset300        if ds_cfg.dataset_id.startswith("ds_"):301            # Internal dataset — load from local store302            raw = Dataset.load_from_disk(f"./data/{ds_cfg.dataset_id}")303        else:304            raw = load_dataset(ds_cfg.dataset_id, split="train")  # type: ignore[assignment]305 306        if ds_cfg.max_samples:307            raw = raw.select(range(min(ds_cfg.max_samples, len(raw))))308 309        if ds_cfg.shuffle:310            raw = raw.shuffle(seed=ds_cfg.shuffle_seed)311 312        n_train = int(len(raw) * ds_cfg.split_ratio)313        splits = DatasetDict(314            {315                "train": raw.select(range(n_train)),316                "eval": raw.select(range(n_train, len(raw))),317            }318        )319        self._log.info(320            "dataset.prepared",321            train_size=len(splits["train"]),322            eval_size=len(splits["eval"]),323            column=ds_cfg.text_column,324        )325        return splits326 327    # ------------------------------------------------------------------328    # Stage 3: Model initialisation329    # ------------------------------------------------------------------330    def _load_model(self) -> tuple[PreTrainedModel, PreTrainedTokenizerBase]:331        self._log.info("pipeline.model_load")332        hp = self._config.training333 334        dtype_map = {335            "fp32": torch.float32,336            "fp16": torch.float16,337            "bf16": torch.bfloat16,338        }339        torch_dtype = dtype_map.get(hp.precision.value, torch.bfloat16)340 341        tokenizer = AutoTokenizer.from_pretrained(self._config.base_model)342        if tokenizer.pad_token is None:343            tokenizer.pad_token = tokenizer.eos_token344 345        model = AutoModelForCausalLM.from_pretrained(346            self._config.base_model,347            torch_dtype=torch_dtype,348            device_map="auto" if torch.cuda.is_available() else "cpu",349        )350 351        if self._config.lora and self._config.lora.enabled:352            lora_cfg = self._config.lora353            peft_config = LoraConfig(354                task_type=TaskType.CAUSAL_LM,355                r=lora_cfg.r,356                lora_alpha=lora_cfg.alpha,357                lora_dropout=lora_cfg.dropout,358                target_modules=lora_cfg.target_modules,359                bias=lora_cfg.bias,  # type: ignore[arg-type]360            )361            model = get_peft_model(model, peft_config)362            trainable, total = model.get_nb_trainable_parameters()363            self._log.info(364                "model.lora_applied",365                trainable_params=trainable,366                total_params=total,367                trainable_pct=round(100 * trainable / total, 2),368            )369        else:370            self._log.info("model.full_finetune")371 372        return model, tokenizer  # type: ignore[return-value]373 374    # ------------------------------------------------------------------375    # Stage 4: Training376    # ------------------------------------------------------------------377    def _train(378        self,379        model: PreTrainedModel,380        tokenizer: PreTrainedTokenizerBase,381        datasets: DatasetDict,382    ) -> None:383        self._log.info("pipeline.training_start")384        hp = self._config.training385        ckpt = self._config.checkpoint386        eval_cfg = self._config.evaluation387 388        def tokenize(examples: dict[str, list[str]]) -> dict[str, Any]:389            return tokenizer(390                examples[self._config.dataset.text_column],391                truncation=True,392                max_length=hp.max_seq_length,393                padding=False,394            )395 396        tokenized = datasets.map(tokenize, batched=True, remove_columns=datasets["train"].column_names)397 398        training_args = TrainingArguments(399            output_dir=str(self._output_dir),400            num_train_epochs=hp.num_epochs,401            per_device_train_batch_size=hp.batch_size,402            per_device_eval_batch_size=hp.batch_size,403            gradient_accumulation_steps=hp.gradient_accumulation_steps,404            learning_rate=hp.learning_rate,405            weight_decay=hp.weight_decay,406            warmup_ratio=hp.warmup_ratio,407            max_grad_norm=hp.max_grad_norm,408            optim=hp.optimizer.value,409            lr_scheduler_type=hp.lr_scheduler,410            fp16=hp.precision.value == "fp16",411            bf16=hp.precision.value == "bf16",412            evaluation_strategy=eval_cfg.strategy.value,413            eval_steps=eval_cfg.eval_steps,414            save_strategy=ckpt.save_strategy.value,415            save_steps=ckpt.save_steps,416            save_total_limit=ckpt.save_total_limit,417            load_best_model_at_end=eval_cfg.load_best_model_at_end,418            metric_for_best_model=eval_cfg.metric_for_best_model.value,419            greater_is_better=eval_cfg.greater_is_better,420            seed=hp.seed,421            dataloader_num_workers=hp.dataloader_num_workers,422            report_to="none",  # structlog handles all logging423            logging_steps=10,424            resume_from_checkpoint=ckpt.resume_from_checkpoint,425            push_to_hub=False,  # push handled separately in export stage426        )427 428        hook_runner = EvalHookRunner(self._config, tokenizer)429        results_path = self._output_dir / "eval_results.json"430        callback = CodeCraftLabCallback(431            hook_runner=hook_runner,432            eval_dataset=datasets["eval"],433            results_path=results_path,434        )435 436        trainer = Trainer(437            model=model,438            args=training_args,439            train_dataset=tokenized["train"],440            eval_dataset=tokenized["eval"],441            data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False),442            callbacks=[callback],443        )444 445        trainer.train(resume_from_checkpoint=ckpt.resume_from_checkpoint)446 447    # ------------------------------------------------------------------448    # Stage 5: Export + Hub push449    # ------------------------------------------------------------------450    def _export(self, model: PreTrainedModel, tokenizer: PreTrainedTokenizerBase) -> Path:451        self._log.info("pipeline.export")452        final_path = self._output_dir / "final"453        model.save_pretrained(str(final_path))454        tokenizer.save_pretrained(str(final_path))455        self._log.info("model.saved", path=str(final_path))456 457        hub_cfg = self._config.hub458        if hub_cfg.push_to_hub and hub_cfg.repo_id:459            self._log.info("hub.pushing", repo_id=hub_cfg.repo_id)460            model.push_to_hub(hub_cfg.repo_id, private=hub_cfg.private)461            tokenizer.push_to_hub(hub_cfg.repo_id, private=hub_cfg.private)462            self._log.info("hub.pushed", repo_id=hub_cfg.repo_id)463 464        return final_path465