Spaces:
Sleeping
Sleeping
| """Base trainer abstraction for all model training pipelines. | |
| Enforces: split discipline, deterministic seed, artifact layout, | |
| metrics schema, and model card generation. All 7 model trainers | |
| inherit from BaseTrainer and implement the abstract methods. | |
| """ | |
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import joblib | |
| import json | |
| import logging | |
| import pandas as pd | |
| from app.core.config import settings | |
| from app.core.exceptions import DatasetError, TrainingError | |
| from app.data.loader import DatasetLoader | |
| logger = logging.getLogger(__name__) | |
| class TrainingResult: | |
| """Result of a single model training run.""" | |
| model_name: str | |
| model_version: str | |
| metrics: dict | |
| artifact_dir: Path | |
| trained_at: datetime | |
| split_counts: dict[str, int] | |
| class BaseTrainer(ABC): | |
| """Abstract base for all model training pipelines. | |
| Enforces: split discipline, deterministic seed, artifact layout, | |
| metrics schema, and model card generation. | |
| """ | |
| def __init__(self, dataset_dir: str | Path, artifact_base_dir: str | Path) -> None: | |
| self._loader = DatasetLoader(dataset_dir) | |
| self._artifact_base_dir = Path(artifact_base_dir) | |
| self._seed = settings.seed | |
| def model_name(self) -> str: | |
| """Unique model identifier, e.g., 'lo_tagger', 'bloom_classifier'.""" | |
| ... | |
| def model_version(self) -> str: | |
| """Version string, e.g., 'lo_tagger_v2_baseline_001'.""" | |
| ... | |
| def table_name(self) -> str: | |
| """CSV table to load, e.g., 'training_lo_tagging'.""" | |
| ... | |
| def artifact_dir(self) -> Path: | |
| """Resolved artifact directory for this model.""" | |
| return self._artifact_base_dir / self.model_name | |
| def run(self) -> TrainingResult: | |
| """Execute the full training pipeline. Returns TrainingResult.""" | |
| logger.info("Starting training for model '%s'", self.model_name) | |
| df = self._loader.load_table(self.table_name) | |
| train_df, val_df, test_df = self._split(df) | |
| self._validate_train_not_empty(train_df) | |
| logger.info( | |
| "Split counts — train: %d, validation: %d, test: %d", | |
| len(train_df), len(val_df), len(test_df), | |
| ) | |
| model_artifacts = self.train(train_df, val_df) | |
| val_metrics = self.evaluate(model_artifacts, val_df, split_name="validation") | |
| test_metrics = self.evaluate(model_artifacts, test_df, split_name="test") | |
| metrics = self._build_metrics(val_metrics, test_metrics, train_df, val_df, test_df) | |
| self._check_baseline(metrics) | |
| self._save_artifacts(model_artifacts, metrics, train_df, val_df, test_df) | |
| trained_at = datetime.now(timezone.utc) | |
| split_counts = { | |
| "train": len(train_df), | |
| "validation": len(val_df), | |
| "test": len(test_df), | |
| } | |
| logger.info("Training complete for model '%s'", self.model_name) | |
| return TrainingResult( | |
| model_name=self.model_name, | |
| model_version=self.model_version, | |
| metrics=metrics, | |
| artifact_dir=self.artifact_dir, | |
| trained_at=trained_at, | |
| split_counts=split_counts, | |
| ) | |
| def _split(self, df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: | |
| """Filter by train_split column. Never shuffles or re-samples.""" | |
| if "train_split" not in df.columns: | |
| raise DatasetError( | |
| f"Table '{self.table_name}' missing 'train_split' column" | |
| ) | |
| train_df = df[df["train_split"] == "train"].copy() | |
| val_df = df[df["train_split"] == "validation"].copy() | |
| test_df = df[df["train_split"] == "test"].copy() | |
| return train_df, val_df, test_df | |
| def _validate_train_not_empty(self, train_df: pd.DataFrame) -> None: | |
| """Raise DatasetError if no training rows available.""" | |
| if len(train_df) == 0: | |
| raise DatasetError( | |
| f"No rows with train_split='train' in table '{self.table_name}'. " | |
| f"Cannot proceed with empty training data." | |
| ) | |
| def train(self, train_df: pd.DataFrame, val_df: pd.DataFrame) -> dict: | |
| """Train the model. Return dict of artifacts (model, vectorizer, etc.).""" | |
| ... | |
| def evaluate(self, artifacts: dict, df: pd.DataFrame, split_name: str) -> dict: | |
| """Evaluate model on a split. Return metrics dict.""" | |
| ... | |
| def _build_metrics( | |
| self, | |
| val_metrics: dict, | |
| test_metrics: dict, | |
| train_df: pd.DataFrame, | |
| val_df: pd.DataFrame, | |
| test_df: pd.DataFrame, | |
| ) -> dict: | |
| """Assemble the full metrics.json content.""" | |
| ... | |
| def _check_baseline(self, metrics: dict) -> None: | |
| """Verify metrics exceed random baseline. Raise TrainingError on failure.""" | |
| ... | |
| def _build_training_config( | |
| self, | |
| train_df: pd.DataFrame, | |
| val_df: pd.DataFrame, | |
| test_df: pd.DataFrame, | |
| ) -> dict: | |
| """Build training_config.json content with hyperparameters.""" | |
| ... | |
| def _build_model_card(self, metrics: dict) -> str: | |
| """Generate model_card.md content.""" | |
| ... | |
| def _save_artifacts( | |
| self, | |
| model_artifacts: dict, | |
| metrics: dict, | |
| train_df: pd.DataFrame, | |
| val_df: pd.DataFrame, | |
| test_df: pd.DataFrame, | |
| ) -> None: | |
| """Save all artifacts to the model's artifact directory.""" | |
| self.artifact_dir.mkdir(parents=True, exist_ok=True) | |
| # Save model objects | |
| for name, obj in model_artifacts.items(): | |
| if name.endswith(".json"): | |
| (self.artifact_dir / name).write_text( | |
| json.dumps(obj, indent=2, default=str), encoding="utf-8" | |
| ) | |
| else: | |
| joblib.dump(obj, self.artifact_dir / f"{name}.joblib", compress=3) | |
| # Save metrics.json | |
| (self.artifact_dir / "metrics.json").write_text( | |
| json.dumps(metrics, indent=2, default=str), encoding="utf-8" | |
| ) | |
| # Save training_config.json | |
| config = self._build_training_config(train_df, val_df, test_df) | |
| (self.artifact_dir / "training_config.json").write_text( | |
| json.dumps(config, indent=2, default=str), encoding="utf-8" | |
| ) | |
| # Save model_card.md | |
| card = self._build_model_card(metrics) | |
| (self.artifact_dir / "model_card.md").write_text(card, encoding="utf-8") | |
| logger.info("Artifacts saved to %s", self.artifact_dir) | |