aaa / training /base_trainer.py
work-sejal
Deploy AI service with FastAPI
70ea7be
Raw
History Blame Contribute Delete
6.87 kB
"""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__)
@dataclass
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
@property
@abstractmethod
def model_name(self) -> str:
"""Unique model identifier, e.g., 'lo_tagger', 'bloom_classifier'."""
...
@property
@abstractmethod
def model_version(self) -> str:
"""Version string, e.g., 'lo_tagger_v2_baseline_001'."""
...
@property
@abstractmethod
def table_name(self) -> str:
"""CSV table to load, e.g., 'training_lo_tagging'."""
...
@property
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."
)
@abstractmethod
def train(self, train_df: pd.DataFrame, val_df: pd.DataFrame) -> dict:
"""Train the model. Return dict of artifacts (model, vectorizer, etc.)."""
...
@abstractmethod
def evaluate(self, artifacts: dict, df: pd.DataFrame, split_name: str) -> dict:
"""Evaluate model on a split. Return metrics dict."""
...
@abstractmethod
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."""
...
@abstractmethod
def _check_baseline(self, metrics: dict) -> None:
"""Verify metrics exceed random baseline. Raise TrainingError on failure."""
...
@abstractmethod
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."""
...
@abstractmethod
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)