- Added a method to persist computed regression metrics (MSE, MAE, R²) as MLflow parameters during model training, enhancing model evaluation and tracking. - Updated the Training class to log the equation path if available, improving artifact management.
334 lines
14 KiB
Python
334 lines
14 KiB
Python
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from jsonschema import Draft202012Validator, ValidationError # type: ignore[import-untyped]
|
|
|
|
# Allowed frontend date formats and their strftime equivalents (single source of truth)
|
|
FRONTEND_DATE_FORMAT_TO_STRFTIME = {
|
|
'dd/MM/yyyy HH:mm:ss': '%d/%m/%Y %H:%M:%S',
|
|
'MM/dd/yyyy HH:mm:ss': '%m/%d/%Y %H:%M:%S',
|
|
'yyyy/MM/dd HH:mm:ss': '%Y/%m/%d %H:%M:%S',
|
|
'dd-MM-yyyy HH:mm:ss': '%d-%m-%Y %H:%M:%S',
|
|
'MM-dd-yyyy HH:mm:ss': '%m-%d-%Y %H:%M:%S',
|
|
'yyyy-MM-dd HH:mm:ss': '%Y-%m-%d %H:%M:%S',
|
|
}
|
|
ALLOWED_FRONTEND_DATE_FORMATS = frozenset(FRONTEND_DATE_FORMAT_TO_STRFTIME.keys())
|
|
|
|
|
|
def validate_frontend_date_format(fmt: str | None) -> None:
|
|
"""Raise ValueError if fmt is set and not one of the allowed frontend date formats."""
|
|
if not fmt or not fmt.strip():
|
|
return
|
|
if fmt not in ALLOWED_FRONTEND_DATE_FORMATS:
|
|
allowed = ', '.join(sorted(ALLOWED_FRONTEND_DATE_FORMATS))
|
|
raise ValueError(f'Invalid date_format "{fmt}". Allowed formats: {allowed}')
|
|
|
|
|
|
# Model name constants
|
|
MODEL_LINEAR_REGRESSION = 'Linear Regression'
|
|
MODEL_POLYNOMIAL_REGRESSION = 'Polynomial Regression'
|
|
|
|
|
|
@dataclass
|
|
class TrainModelParams:
|
|
"""
|
|
Parameters for machine learning model training.
|
|
|
|
This class encapsulates all configuration parameters required for the training
|
|
pipeline, including data processing settings, model configuration, and experiment
|
|
tracking information. All parameters are validated upon initialization to ensure
|
|
data integrity and prevent runtime errors.
|
|
|
|
Use the `from_dict()` class method to create instances from dictionaries with
|
|
automatic validation of all fields.
|
|
|
|
Attributes:
|
|
variable_columns (list[str]): List of variable column names to use as features.
|
|
target_variable (str): Name of the target variable to predict.
|
|
bucket_name (str): Name of the MinIO bucket containing training data.
|
|
file_name (str): Name of the file in the MinIO bucket.
|
|
line_separator (str): Line separator used in the CSV file.
|
|
decimal_separator (str): Decimal separator used in the CSV file.
|
|
date_column (str | None): Name of the date/time column. If set with date_format, the column is parsed as datetime.
|
|
date_format (str | None): Format of the date column (e.g. dd/MM/yyyy HH:mm:ss). Used when date_column is set.
|
|
train_size (int): Percentage of data to use for training (0-100).
|
|
shuffle (bool): Whether to shuffle the data during train/test split.
|
|
experiment_run_id (int): Unique identifier for the experiment run.
|
|
model_name (str): Name of the model type ('Linear Regression' or 'Polynomial Regression').
|
|
val_file_name (str | None): Name of the validation file in the MinIO bucket.
|
|
data_model_kwargs (dict | None): Keyword arguments for the data model.
|
|
model_kwargs (dict | None): Keyword arguments for the model.
|
|
opt_params (dict | None): Keyword optimazation arguments for the wrapper.
|
|
model_type (str): Type of the model to use (ex.: 'Linear Regression', 'XGBoost').
|
|
"""
|
|
|
|
# Old Parameters (keep)
|
|
|
|
variable_columns: list[str]
|
|
target_variable: str
|
|
bucket_name: str
|
|
file_name: str
|
|
line_separator: str
|
|
decimal_separator: str
|
|
date_column: str | None
|
|
date_format: str | None
|
|
train_size: int
|
|
shuffle: bool
|
|
random_state: int
|
|
experiment_run_id: int
|
|
model_name: str
|
|
experiment_name: str
|
|
|
|
# New Parameters
|
|
val_file_name: str | None
|
|
data_model_kwargs: dict | None # Removed params used in DataPreprocessor here
|
|
model_kwargs: dict | None # Removed params used in Linear Regression Model here
|
|
opt_params: dict | None
|
|
model_type: str
|
|
model_id: str | None
|
|
|
|
# Context Parameters
|
|
model_metadata: dict | None
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> 'TrainModelParams':
|
|
"""
|
|
Create TrainModelParams from dictionary with validation.
|
|
|
|
This factory method creates a TrainModelParams instance from a dictionary,
|
|
applying validation to ensure all required fields are present and have
|
|
the correct types. This is the recommended way to create instances from
|
|
workflow input data.
|
|
|
|
Args:
|
|
data: Dictionary containing training parameters with keys matching the
|
|
attribute names (e.g. variable_columns, data_model_kwargs, model_kwargs, opt_params).
|
|
model_metadata may be omitted or None until load_model_metadata fills it.
|
|
experiment_run_id may be an int or numeric string.
|
|
|
|
Returns:
|
|
TrainModelParams: Validated instance with all fields populated
|
|
|
|
Raises:
|
|
ValueError: If any required field is missing or None
|
|
TypeError: If any field has an incorrect type
|
|
KeyError: If any required key is missing from the dictionary
|
|
"""
|
|
# `from_dict()` should only build the "raw" object from the input dict.
|
|
# Semantic validation and defaults must be handled by `validate_business_rules()`
|
|
# (using `model_metadata` JSON Schemas).
|
|
|
|
model_name = cls._check_none(data.get('model_name'), str, 'model_name')
|
|
|
|
return cls(
|
|
variable_columns=cls._check_none(
|
|
data.get('variable_columns'), list, 'variable_columns'
|
|
),
|
|
target_variable=cls._check_none(data.get('target_variable'), str, 'target_variable'),
|
|
bucket_name=cls._check_none(data.get('bucket_name'), str, 'bucket_name'),
|
|
file_name=cls._check_none(data.get('file_name'), str, 'file_name'),
|
|
line_separator=cls._check_none(data.get('line_separator'), str, 'line_separator'),
|
|
decimal_separator=cls._check_none(
|
|
data.get('decimal_separator'), str, 'decimal_separator'
|
|
),
|
|
date_column=data.get('date_column'),
|
|
date_format=data.get('date_format'),
|
|
train_size=cls._check_none(data.get('train_size'), int, 'train_size'),
|
|
shuffle=cls._check_none(data.get('shuffle'), bool, 'shuffle'),
|
|
random_state=cls._check_none(data.get('random_state', 42), int, 'random_state'),
|
|
experiment_run_id=cls._coerce_experiment_run_id(data.get('experiment_run_id')),
|
|
model_name=model_name,
|
|
experiment_name=model_name + '_experiment',
|
|
val_file_name=data.get('val_file_name'),
|
|
data_model_kwargs=cls._check_none(
|
|
data.get('data_model_kwargs'), dict, 'data_model_kwargs'
|
|
),
|
|
model_kwargs=cls._check_none(data.get('model_kwargs'), dict, 'model_kwargs'),
|
|
opt_params=cls._check_none(data.get('opt_params'), dict, 'opt_params'),
|
|
model_type=cls._check_none(data.get('model_type'), str, 'model_type'),
|
|
model_id=data.get('model_id'),
|
|
model_metadata=cls._parse_optional_model_metadata(data.get('model_metadata')),
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""
|
|
Convert TrainModelParams to a dictionary.
|
|
"""
|
|
return self.__dict__
|
|
|
|
@staticmethod
|
|
def _check_none(value: Any | None, expected_type: type, field_name: str) -> Any:
|
|
"""
|
|
Validate that a value is not None and check its type.
|
|
|
|
This method ensures that required parameters are provided and have the
|
|
correct type, raising descriptive errors if validation fails.
|
|
|
|
Args:
|
|
value (Any | None): The value to validate.
|
|
expected_type (type): The expected type of the value.
|
|
field_name (str): The name of the field being validated (for error messages).
|
|
|
|
Returns:
|
|
Any: The validated value if it is not None and matches the expected type.
|
|
|
|
Raises:
|
|
ValueError: If the value is None.
|
|
TypeError: If the value is not of the expected type.
|
|
"""
|
|
if value is None:
|
|
error = f'{field_name} is required and cannot be None.'
|
|
raise ValueError(error)
|
|
|
|
return TrainModelParams._check_type(value, expected_type, field_name)
|
|
|
|
@staticmethod
|
|
def _check_type(value: Any | None, expected_type: type, field_name: str) -> Any:
|
|
"""
|
|
Validate that a value matches the expected type.
|
|
|
|
This method checks type compatibility and raises a descriptive error
|
|
if the value does not match the expected type.
|
|
|
|
Args:
|
|
value (Any | None): The value to validate.
|
|
expected_type (type): The expected type of the value.
|
|
field_name (str): The name of the field being validated (for error messages).
|
|
|
|
Returns:
|
|
Any: The validated value if it matches the expected type.
|
|
|
|
Raises:
|
|
TypeError: If the value is not of the expected type.
|
|
"""
|
|
if value is not None and not isinstance(value, expected_type):
|
|
error = f'{field_name} must be of type {expected_type.__name__}, but got {type(value).__name__}.'
|
|
raise TypeError(error)
|
|
|
|
return value
|
|
|
|
@staticmethod
|
|
def _coerce_experiment_run_id(value: Any) -> int:
|
|
"""
|
|
Coerce experiment_run_id to int.
|
|
|
|
Workflow clients may send numeric strings; this keeps from_dict aligned with
|
|
workflow validation.
|
|
|
|
Args:
|
|
value: Raw experiment_run_id from the payload.
|
|
|
|
Returns:
|
|
int: Parsed experiment run id.
|
|
|
|
Raises:
|
|
ValueError: If the value is None.
|
|
TypeError: If the value cannot be coerced to a non-boolean integer.
|
|
"""
|
|
if value is None:
|
|
raise ValueError('experiment_run_id is required and cannot be None.')
|
|
if isinstance(value, bool):
|
|
raise TypeError('experiment_run_id must be an integer, got bool.')
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, str) and value.strip().isdigit():
|
|
return int(value.strip())
|
|
if isinstance(value, float) and value.is_integer():
|
|
return int(value)
|
|
raise TypeError(
|
|
f'experiment_run_id must be an integer or numeric string, but got {type(value).__name__}.'
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_optional_model_metadata(value: Any) -> dict | None:
|
|
"""
|
|
Parse model_metadata for from_dict before load_model_metadata fills the index.
|
|
|
|
Args:
|
|
value: model_metadata from the payload, or None if not sent yet.
|
|
|
|
Returns:
|
|
dict | None: Dict when provided; None when absent (filled later by load_model_metadata).
|
|
|
|
Raises:
|
|
TypeError: If value is neither None nor a dict.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, dict):
|
|
return value
|
|
raise TypeError(f'model_metadata must be a dict or None, but got {type(value).__name__}.')
|
|
|
|
def validate_business_rules(self) -> None:
|
|
"""
|
|
Validate business rules and constraints for training parameters.
|
|
|
|
This method performs additional validation beyond type checking to ensure
|
|
that parameter values are within acceptable ranges and logically consistent.
|
|
It implements defense-in-depth validation to catch configuration errors
|
|
early in the workflow.
|
|
|
|
Raises:
|
|
ValueError: If any business rule is violated
|
|
"""
|
|
self._validate_numeric_ranges()
|
|
self._validate_model_params()
|
|
self._validate_required_strings()
|
|
self._validate_date_format()
|
|
|
|
def _validate_numeric_ranges(self) -> None:
|
|
"""Validate numeric parameters are within acceptable ranges."""
|
|
if not 10 <= self.train_size <= 100:
|
|
raise ValueError(f'train_size must be between 10 and 100, got {self.train_size}')
|
|
|
|
if not self.variable_columns:
|
|
raise ValueError('variable_columns cannot be empty')
|
|
|
|
def _validate_model_params(self) -> None:
|
|
"""Validate model-related parameters."""
|
|
if not self.model_metadata:
|
|
raise ValueError('model_metadata is required')
|
|
|
|
schemas = self.model_metadata.get('schemas', {}).get('components', {}).get('schemas')
|
|
|
|
if not schemas:
|
|
return
|
|
|
|
data_model_schema = schemas.get('data_model')
|
|
model_schema = schemas.get('model')
|
|
opt_params_schema = schemas.get('opt_params')
|
|
|
|
if data_model_schema:
|
|
self._validate_model_param(data_model_schema, self.data_model_kwargs)
|
|
if model_schema:
|
|
self._validate_model_param(model_schema, self.model_kwargs)
|
|
if opt_params_schema:
|
|
self._validate_model_param(opt_params_schema, self.opt_params)
|
|
|
|
def _validate_model_param(self, schema: dict[str, Any], value: Any) -> None:
|
|
"""Validate model parameter against schema."""
|
|
try:
|
|
validator = Draft202012Validator(schema)
|
|
validator.validate(value)
|
|
except ValidationError as e:
|
|
raise ValueError(f'Model parameters validation failed: {e.message}') from e
|
|
|
|
def _validate_required_strings(self) -> None:
|
|
"""Validate required string fields are not empty."""
|
|
if not self.target_variable.strip():
|
|
raise ValueError('target_variable cannot be empty or whitespace')
|
|
|
|
if not self.bucket_name.strip():
|
|
raise ValueError('bucket_name cannot be empty or whitespace')
|
|
|
|
if not self.file_name.strip():
|
|
raise ValueError('file_name cannot be empty or whitespace')
|
|
|
|
if not self.model_name.strip():
|
|
raise ValueError('model_name cannot be empty or whitespace')
|
|
|
|
def _validate_date_format(self) -> None:
|
|
"""Validate date_format is one of the allowed frontend formats when set."""
|
|
if self.date_format:
|
|
validate_frontend_date_format(self.date_format)
|