Code import - branch release/SIENTIAPDE-1645
This commit is contained in:
0
model_manager/utils/__init__.py
Normal file
0
model_manager/utils/__init__.py
Normal file
166
model_manager/utils/connectors_config.py
Normal file
166
model_manager/utils/connectors_config.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from os import getenv
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_postgres_config() -> dict[str, Any]:
|
||||
"""
|
||||
Build PostgreSQL database configuration from environment variables.
|
||||
|
||||
This function constructs a PostgreSQL configuration dictionary from
|
||||
environment variables with sensible defaults for local development.
|
||||
It handles connection pool configuration and security parameters.
|
||||
|
||||
Environment Variables:
|
||||
POSTGRES_HOST: Database hostname (default: localhost)
|
||||
POSTGRES_PORT: Database port (default: 5432)
|
||||
POSTGRES_USER: Database username (default: sientia)
|
||||
POSTGRES_PASSWORD: Database password (default: sientia)
|
||||
POSTGRES_DBNAME: Database name (default: sientia)
|
||||
POSTGRES_MIN_CONNECTIONS: Minimum connection pool size (default: 5)
|
||||
POSTGRES_MAX_CONNECTIONS: Maximum connection pool size (default: 20)
|
||||
|
||||
Returns:
|
||||
dict: PostgreSQL configuration dictionary with all required parameters
|
||||
"""
|
||||
return {
|
||||
'host': getenv('POSTGRES_HOST', 'localhost'),
|
||||
'port': int(getenv('POSTGRES_PORT', '5432')),
|
||||
'user': getenv('POSTGRES_USER', 'sientia'),
|
||||
'password': getenv('POSTGRES_PASSWORD', 'sientia'),
|
||||
'dbname': getenv('POSTGRES_DBNAME', 'sientia'),
|
||||
'min_connections': int(getenv('POSTGRES_MIN_CONNECTIONS', '5')),
|
||||
'max_connections': int(getenv('POSTGRES_MAX_CONNECTIONS', '20')),
|
||||
}
|
||||
|
||||
|
||||
def build_mlflow_config() -> dict[str, Any]:
|
||||
"""
|
||||
Build MLFlow server configuration from environment variables.
|
||||
|
||||
This function constructs an MLFlow configuration dictionary from
|
||||
environment variables with sensible defaults for local development.
|
||||
It handles server connection and authentication parameters.
|
||||
|
||||
Environment Variables:
|
||||
MLFLOW_URL: Full MLflow tracking URL including scheme, host, and port
|
||||
(default: http://localhost:5080)
|
||||
MLFLOW_USERNAME: MLFlow username (default: aignosi)
|
||||
MLFLOW_PASSWORD: MLFlow password (default: aignosi)
|
||||
|
||||
Returns:
|
||||
dict: MLFlow configuration dictionary with all required parameters
|
||||
"""
|
||||
return {
|
||||
'url': getenv('MLFLOW_URL', 'http://localhost:5080'),
|
||||
'username': getenv('MLFLOW_USERNAME', 'aignosi'),
|
||||
'password': getenv('MLFLOW_PASSWORD', 'aignosi'),
|
||||
}
|
||||
|
||||
|
||||
def build_mongodb_config() -> dict[str, Any]:
|
||||
"""
|
||||
Build MongoDB configuration from environment variables.
|
||||
|
||||
This function constructs a MongoDB configuration dictionary from
|
||||
environment variables with sensible defaults for local development.
|
||||
It handles connection string and database name configuration.
|
||||
|
||||
Environment Variables:
|
||||
MONGODB_USERNAME: MongoDB username (default: root)
|
||||
MONGODB_PASSWORD: MongoDB password (default: wKZDbMNU1c)
|
||||
MONGODB_URL: MongoDB connection URI (default: localhost:27018)
|
||||
MONGODB_DATABASE: MongoDB database name (default: sientia)
|
||||
MONGODB_TTL_INDEX_HOURS: TTL index duration in hours (default: 1)
|
||||
|
||||
Returns:
|
||||
dict: MongoDB configuration dictionary with connection parameters
|
||||
"""
|
||||
username = getenv('MONGODB_USERNAME', 'root')
|
||||
password = getenv('MONGODB_PASSWORD', 'wKZDbMNU1c')
|
||||
uri = getenv('MONGODB_URL', 'localhost:27018')
|
||||
|
||||
connection_string = f'mongodb://{username}:{password}@{uri}'
|
||||
|
||||
return {
|
||||
'connection_string': connection_string,
|
||||
'database_name': getenv('MONGODB_DATABASE', 'sientia'),
|
||||
'ttl_index_seconds': int(getenv('MONGODB_TTL_INDEX_HOURS', '1')) * 3600,
|
||||
'uri': uri,
|
||||
}
|
||||
|
||||
|
||||
def build_minio_config() -> dict[str, Any]:
|
||||
"""
|
||||
Build MinIO (S3-compatible) configuration from environment variables.
|
||||
|
||||
This function constructs a MinIO configuration dictionary from
|
||||
environment variables with sensible defaults for local development.
|
||||
It handles endpoint URL, authentication, connection parameters, and retry policies.
|
||||
|
||||
Environment Variables:
|
||||
MINIO_ENDPOINT_URL: MinIO server endpoint URL (default: http://localhost:9000)
|
||||
MINIO_ACCESS_KEY: MinIO access key ID (default: minioadmin)
|
||||
MINIO_SECRET_KEY: MinIO secret access key (default: minioadmin)
|
||||
MINIO_REGION: MinIO region name (default: us-east-1)
|
||||
MINIO_SECURE: Whether to use SSL/TLS (default: false)
|
||||
MINIO_MAX_RETRY_ATTEMPTS: Maximum number of retry attempts (default: 3)
|
||||
MINIO_RETRY_MODE: Retry mode - standard, legacy, or adaptive (default: adaptive)
|
||||
MINIO_CONNECT_TIMEOUT: Connection timeout in seconds (default: 10)
|
||||
MINIO_READ_TIMEOUT: Read timeout in seconds (default: 60)
|
||||
MINIO_DEFAULT_BUCKET: Default S3 bucket for MinioRepository (default: model-training)
|
||||
|
||||
Returns:
|
||||
dict: MinIO configuration dictionary with all required parameters
|
||||
"""
|
||||
return {
|
||||
'endpoint_url': getenv('MINIO_ENDPOINT_URL', 'http://localhost:9000'),
|
||||
'access_key': getenv('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||
'secret_key': getenv('MINIO_SECRET_KEY', 'minioadmin'),
|
||||
'region': getenv('MINIO_REGION', 'us-east-1'),
|
||||
'use_ssl': getenv('MINIO_SECURE', 'false').lower() == 'true',
|
||||
'max_retry_attempts': int(getenv('MINIO_MAX_RETRY_ATTEMPTS', '3')),
|
||||
'retry_mode': getenv('MINIO_RETRY_MODE', 'adaptive'),
|
||||
'connect_timeout': int(getenv('MINIO_CONNECT_TIMEOUT', '10')),
|
||||
'read_timeout': int(getenv('MINIO_READ_TIMEOUT', '60')),
|
||||
'default_bucket': getenv('MINIO_DEFAULT_BUCKET', 'model-training'),
|
||||
}
|
||||
|
||||
|
||||
def build_plugin_store_config() -> dict[str, Any]:
|
||||
"""
|
||||
Build PluginStore configuration from environment variables.
|
||||
|
||||
This function constructs a configuration dictionary for the PluginStore
|
||||
client using environment variables with sensible defaults for local
|
||||
development.
|
||||
|
||||
Environment Variables:
|
||||
STORE_BASE_URL: Base URL of the PluginStore backing Git server
|
||||
(default: http://localhost:3000)
|
||||
STORE_OWNER: Repository owner/organization (default: sientia)
|
||||
STORE_REPO: Repository name (default: model-library-store)
|
||||
STORE_BRANCH: Optional branch name
|
||||
STORE_USERNAME: Optional username for Git HTTP authentication
|
||||
STORE_PASSWORD: Optional password/token for Git HTTP authentication
|
||||
PYPI_SERVER: Optional custom PyPI index URL for runtime installation
|
||||
(default: http://localhost:5000)
|
||||
PYPI_USERNAME: Optional username for PyPI authentication
|
||||
PYPI_PASSWORD: Optional password/token for PyPI authentication
|
||||
|
||||
Returns:
|
||||
dict: PluginStore configuration dictionary with all connector parameters
|
||||
"""
|
||||
|
||||
cache_ttl_seconds = getenv('STORE_CACHE_TTL_SECONDS')
|
||||
return {
|
||||
'base_url': getenv('STORE_BASE_URL', 'http://localhost:3000'),
|
||||
'owner': getenv('STORE_OWNER', 'sientia'),
|
||||
'repo': getenv('STORE_REPO', 'model-library-store'),
|
||||
'username': getenv('STORE_USERNAME'),
|
||||
'password': getenv('STORE_PASSWORD'),
|
||||
'branch': getenv('STORE_BRANCH'),
|
||||
'cache_ttl_seconds': int(cache_ttl_seconds) if cache_ttl_seconds else None,
|
||||
'pypi_index_url': getenv('PYPI_SERVER', 'http://localhost:5000'),
|
||||
'pypi_username': getenv('PYPI_USERNAME'),
|
||||
'pypi_password': getenv('PYPI_PASSWORD'),
|
||||
}
|
||||
27
model_manager/utils/logger_helper.py
Normal file
27
model_manager/utils/logger_helper.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Logger helper to prevent duplicate logs caused by propagation.
|
||||
|
||||
This module provides a wrapper around sientia_do Logger to disable
|
||||
log propagation and prevent duplicate log entries in the Model Manager.
|
||||
"""
|
||||
|
||||
from sientia_do.observability.logger import Logger as SientiaLogger
|
||||
|
||||
|
||||
def get_logger(name: str) -> SientiaLogger:
|
||||
"""
|
||||
Create a Logger instance with propagation disabled.
|
||||
|
||||
This prevents duplicate logs caused by hierarchical propagation
|
||||
in Python's logging system.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__ of the calling module).
|
||||
|
||||
Returns:
|
||||
Logger: Configured logger instance with propagation disabled.
|
||||
"""
|
||||
logger = SientiaLogger(name)
|
||||
# Disable propagation to prevent duplicate logs
|
||||
logger.base_logger.propagate = False
|
||||
return logger
|
||||
16
model_manager/utils/models/__init__.py
Normal file
16
model_manager/utils/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Models and DTOs for the Model Manager system.
|
||||
|
||||
This module contains data transfer objects (DTOs) and model classes used
|
||||
throughout the Model Manager workflows and activities.
|
||||
"""
|
||||
|
||||
from model_manager.utils.models.experiment_status import ExperimentStatus
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
|
||||
__all__ = [
|
||||
'ExperimentStatus',
|
||||
'TrainModelParams',
|
||||
'TrainModelResult',
|
||||
]
|
||||
26
model_manager/utils/models/experiment_status.py
Normal file
26
model_manager/utils/models/experiment_status.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ExperimentStatus(StrEnum):
|
||||
"""
|
||||
Status values for experiment run lifecycle.
|
||||
|
||||
This enum defines all possible status values that an experiment run can have
|
||||
throughout its lifecycle, from initialization through training, model saving,
|
||||
and cleanup. These statuses are used to track progress and identify failures
|
||||
in the training pipeline.
|
||||
|
||||
The status values follow the naming convention from the original Mage pipeline
|
||||
to maintain compatibility with existing database records and monitoring systems.
|
||||
|
||||
Attributes:
|
||||
ORCHESTRATOR_WAITING_PROC: Initial status indicating experiment is registered and waiting for processing.
|
||||
ORCHESTRATOR_VALIDATION_ERROR: Error in the parameters validation.
|
||||
TRAINING_SUCCESS: Training completed successfully with model and metrics calculated.
|
||||
TRAINING_ERROR: Training failed due to data issues, model errors, or other exceptions.
|
||||
"""
|
||||
|
||||
ORCHESTRATOR_VALIDATION_ERROR = 'ORCHESTRATOR_VALIDATION_ERROR'
|
||||
ORCHESTRATOR_WAITING_PROC = 'ORCHESTRATOR_WAITING_PROC'
|
||||
TRAINING_SUCCESS = 'TRAINING_SUCCESS'
|
||||
TRAINING_ERROR = 'TRAINING_ERROR'
|
||||
363
model_manager/utils/models/train_model_params.py
Normal file
363
model_manager/utils/models/train_model_params.py
Normal file
@@ -0,0 +1,363 @@
|
||||
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())
|
||||
|
||||
# When the client omits date_format (or sends null/blank), parsing uses this frontend format.
|
||||
DEFAULT_TRAIN_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'
|
||||
|
||||
|
||||
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): Name of the date/time column in the dataset (required).
|
||||
date_format (str): Format of the date column (allowed frontend strings). If omitted or blank
|
||||
in the input dict, defaults to DEFAULT_TRAIN_DATE_FORMAT.
|
||||
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
|
||||
date_format: str
|
||||
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, date_column, data_model_kwargs, model_kwargs, opt_params).
|
||||
Unknown keys are ignored by from_dict; missing required snake_case keys raise.
|
||||
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=cls._check_none(data.get('date_column'), str, 'date_column'),
|
||||
date_format=cls._resolve_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,
|
||||
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')),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_date_format(raw: Any) -> str:
|
||||
"""
|
||||
Resolve date_format from workflow input.
|
||||
|
||||
Omitted, null, or blank values use DEFAULT_TRAIN_DATE_FORMAT. Non-string types raise.
|
||||
|
||||
Args:
|
||||
raw: Raw date_format from the payload, or None if absent.
|
||||
|
||||
Return:
|
||||
str: Canonical frontend date format string.
|
||||
"""
|
||||
if raw is None:
|
||||
return DEFAULT_TRAIN_DATE_FORMAT
|
||||
if isinstance(raw, str) and not raw.strip():
|
||||
return DEFAULT_TRAIN_DATE_FORMAT
|
||||
if not isinstance(raw, str):
|
||||
raise TypeError(
|
||||
f'date_format must be a string or omitted, but got {type(raw).__name__}.'
|
||||
)
|
||||
return raw.strip()
|
||||
|
||||
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')
|
||||
|
||||
if not self.date_column.strip():
|
||||
raise ValueError('date_column cannot be empty or whitespace')
|
||||
|
||||
def _validate_date_format(self) -> None:
|
||||
"""Validate date_format is one of the allowed frontend formats."""
|
||||
validate_frontend_date_format(self.date_format)
|
||||
53
model_manager/utils/models/train_model_result.py
Normal file
53
model_manager/utils/models/train_model_result.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainModelResult:
|
||||
"""
|
||||
A data container for storing the results of a machine learning training process.
|
||||
|
||||
This dataclass encapsulates all outputs from the training pipeline, including
|
||||
the prepared datasets, evaluation metrics, and paths to generated artifacts.
|
||||
It is used to pass results between activities in the training workflow.
|
||||
|
||||
Attributes:
|
||||
params (TrainModelParams): The parameters used to train the model.
|
||||
x_train (pd.DataFrame): The training dataset features.
|
||||
x_test (pd.DataFrame): The testing dataset features.
|
||||
y_train (pd.DataFrame): The training dataset target values.
|
||||
y_test (pd.DataFrame): The testing dataset target values.
|
||||
y_pred (pd.Series | None): The predicted target values for the testing dataset. Default is None.
|
||||
y_train_pred (pd.Series | None): The predicted target values for the training dataset. Default is None.
|
||||
mse_val (float | None): The Mean Squared Error (MSE) of the predictions. Default is None.
|
||||
mae_val (float | None): The Mean Absolute Error (MAE) of the predictions. Default is None.
|
||||
r2_val (float | None): The R-squared (R²) value of the predictions. Default is None.
|
||||
equation (dict | None): The equation of the model. Default is None.
|
||||
equation_path (str | None): The path to the equation file. Default is None.
|
||||
run_name (str | None): The name of the MLFlow run. Default is None.
|
||||
report_path (str | None): The path to the generated HTML report file. Default is None.
|
||||
train_data_path (str | None): The path to the training dataset CSV file. Default is None.
|
||||
test_data_path (str | None): The path to the testing dataset CSV file. Default is None.
|
||||
"""
|
||||
|
||||
params: TrainModelParams
|
||||
train_data: pd.DataFrame
|
||||
val_data: pd.DataFrame
|
||||
y_pred: pd.DataFrame | None = None
|
||||
y_train_pred: pd.DataFrame | None = None
|
||||
mse_val: float | None = None
|
||||
mae_val: float | None = None
|
||||
r2_val: float | None = None
|
||||
equation: dict | None = None
|
||||
equation_path: str | None = None
|
||||
run_name: str | None = None
|
||||
experiment_name: str | None = None
|
||||
run_id: str | None = None
|
||||
report_path: str | None = None
|
||||
train_data_path: str | None = None
|
||||
test_data_path: str | None = None
|
||||
|
||||
run_dir: str | None = None
|
||||
636
model_manager/utils/repository/data_manager_repository.py
Normal file
636
model_manager/utils/repository/data_manager_repository.py
Normal file
@@ -0,0 +1,636 @@
|
||||
"""
|
||||
Data management repository for the training pipeline.
|
||||
|
||||
This module provides the core data loading and preprocessing logic for the
|
||||
training pipeline, including:
|
||||
- CSV loading from in-memory bytes
|
||||
- datetime parsing and index configuration
|
||||
- optional support filters
|
||||
- train/test split management (when no explicit validation dataset is provided)
|
||||
|
||||
It is intentionally decoupled from any specific model implementation or MLflow
|
||||
integration. Models are trained elsewhere (e.g., via SientiaModel wrappers),
|
||||
and this repository focuses solely on preparing data structures for them.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from os import makedirs, path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
from sientia_model.wrappers.sientia_model import SientiaModel
|
||||
|
||||
from model_manager.runtime_paths import PROJECT_BASE_PATH, REPORTS_ROOT
|
||||
from model_manager.sientia.metrics import mae, mse, r2
|
||||
from model_manager.sientia.reports import Reports # type: ignore[import-untyped]
|
||||
from model_manager.utils.models.train_model_params import (
|
||||
FRONTEND_DATE_FORMAT_TO_STRFTIME,
|
||||
TrainModelParams,
|
||||
)
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
|
||||
|
||||
def train_test_split(
|
||||
data: pd.DataFrame,
|
||||
train_size: float,
|
||||
random_state: int | None = None,
|
||||
shuffle: bool = True,
|
||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
# 1. Definir a semente (seed) para reprodutibilidade
|
||||
if random_state is not None:
|
||||
np.random.seed(random_state)
|
||||
|
||||
# 2. Gerar índices e embaralhar se necessário
|
||||
indices = np.arange(len(data))
|
||||
|
||||
if shuffle:
|
||||
np.random.shuffle(indices)
|
||||
|
||||
# 3. Calcular o ponto de corte (split point)
|
||||
# Cálculo: N_treino = tamanho_total * proporcao_treino
|
||||
n_train = int(len(data) * train_size)
|
||||
|
||||
# 4. Dividir os índices
|
||||
train_indices = indices[:n_train]
|
||||
test_indices = indices[n_train:]
|
||||
|
||||
# 5. Retornar os dados fatiados
|
||||
return data.iloc[train_indices], data.iloc[test_indices]
|
||||
|
||||
|
||||
def _ensure_date_column_parsed(data: pd.DataFrame, params: TrainModelParams) -> pd.DataFrame:
|
||||
"""
|
||||
Parse params.date_column using the frontend date_format mapping only.
|
||||
|
||||
date_column must exist in ``data`` (callers validate before prepare). No broad
|
||||
pandas inference or alternate timezone formats here—clients must send a supported
|
||||
date_format or rely on the TrainModelParams default.
|
||||
"""
|
||||
if params.date_column not in data.columns:
|
||||
raise ValueError(
|
||||
f'date_column "{params.date_column}" not found in dataset columns: {list(data.columns)}'
|
||||
)
|
||||
data = data.copy()
|
||||
col = data[params.date_column]
|
||||
if params.date_format not in FRONTEND_DATE_FORMAT_TO_STRFTIME:
|
||||
raise ValueError(
|
||||
f'date_format "{params.date_format}" is not mapped to a strftime pattern '
|
||||
'(must be one of the allowed frontend formats).'
|
||||
)
|
||||
strf = FRONTEND_DATE_FORMAT_TO_STRFTIME[params.date_format]
|
||||
try:
|
||||
parsed = pd.to_datetime(col, format=strf, errors='raise')
|
||||
data[params.date_column] = parsed
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f'Failed to parse date column "{params.date_column}" with format "{params.date_format}": {e}'
|
||||
) from e
|
||||
return data
|
||||
|
||||
|
||||
class DataManagerRepository(SientiaMonitoring):
|
||||
"""
|
||||
Repository for data preparation in the training pipeline.
|
||||
|
||||
This class encapsulates the core logic for preparing ML training data:
|
||||
loading CSV bytes, applying date/index configuration, support filters, and
|
||||
constructing train/test splits (or using an explicit validation dataset).
|
||||
|
||||
Attributes:
|
||||
logger (Logger): Logger instance for observability and debugging
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Logger):
|
||||
"""
|
||||
Initialize DataManagerRepository with logger.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for observability
|
||||
"""
|
||||
SientiaMonitoring.__init__(
|
||||
self,
|
||||
logger=logger,
|
||||
notification_handler=None,
|
||||
metrics_controller=None,
|
||||
)
|
||||
|
||||
def _drop_rows_with_missing_timestamp(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
params: TrainModelParams,
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Remove rows where the configured date_column is missing (NaN/NaT/blank string).
|
||||
|
||||
Empty timestamp cells cannot be placed on a DatetimeIndex and break
|
||||
downstream joins and metrics.
|
||||
"""
|
||||
if params.date_column not in df.columns:
|
||||
return df
|
||||
series = df[params.date_column]
|
||||
mask = series.notna()
|
||||
if series.dtype == object:
|
||||
stripped = series.astype(str).str.strip()
|
||||
mask &= stripped.ne('')
|
||||
mask &= stripped.str.lower().ne('nan')
|
||||
n_drop = int((~mask).sum())
|
||||
if n_drop:
|
||||
self.info(
|
||||
f'Dropping {n_drop} row(s) with missing or blank timestamp column '
|
||||
f'"{params.date_column}"',
|
||||
metadata,
|
||||
)
|
||||
return df.loc[mask].copy()
|
||||
|
||||
def _coerce_non_timestamp_columns_to_numeric(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
params: TrainModelParams,
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Coerce all non-timestamp columns to numeric dtype.
|
||||
|
||||
The timestamp column defined by params.date_column is excluded from coercion.
|
||||
Non-numeric values are coerced to NaN.
|
||||
"""
|
||||
out = df.copy()
|
||||
for col in out.columns:
|
||||
if col == params.date_column:
|
||||
continue
|
||||
original_na = int(out[col].isna().sum())
|
||||
out[col] = pd.to_numeric(out[col], errors='coerce')
|
||||
new_na = int(out[col].isna().sum())
|
||||
introduced_na = new_na - original_na
|
||||
if introduced_na > 0:
|
||||
self.warning(
|
||||
f'Column "{col}" had {introduced_na} non-numeric value(s) coerced to NaN',
|
||||
metadata,
|
||||
)
|
||||
return out
|
||||
|
||||
def prepare_training_data(
|
||||
self,
|
||||
train_file_bytes: bytes,
|
||||
validation_file_bytes: bytes | None,
|
||||
params: TrainModelParams,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> TrainModelResult:
|
||||
"""
|
||||
Build TrainModelResult from raw CSV bytes for train (and optional validation) data.
|
||||
|
||||
This method orchestrates the data pipeline:
|
||||
1. Load training data from in-memory bytes
|
||||
2. Optionally load validation data from in-memory bytes
|
||||
3. Parse and configure datetime index
|
||||
4. Apply optional support filters
|
||||
5. Split into train/test sets when no explicit validation dataset is provided
|
||||
|
||||
Args:
|
||||
train_file_bytes: Raw bytes of the training CSV.
|
||||
validation_file_bytes: Raw bytes of the validation CSV, or None when
|
||||
validation should be derived via train/test split.
|
||||
params: Training parameters (TrainModelParams).
|
||||
|
||||
Returns:
|
||||
TrainModelResult: Object containing processed data, train/test splits,
|
||||
and scaler dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If transformed data is empty.
|
||||
Exception: If data loading or preprocessing fails.
|
||||
"""
|
||||
try:
|
||||
train_df = pd.read_csv(
|
||||
BytesIO(train_file_bytes),
|
||||
sep=params.line_separator,
|
||||
decimal=params.decimal_separator,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
'Failed to load training CSV data from MinIO object. '
|
||||
'Check file encoding, line separator and decimal separator.'
|
||||
) from exc
|
||||
|
||||
train_df = self._drop_rows_with_missing_timestamp(train_df, params, metadata)
|
||||
train_df = _ensure_date_column_parsed(train_df, params)
|
||||
train_df = self._configure_datetime_index(train_df, params, metadata)
|
||||
train_df = self._set_timezone_on_index(train_df, metadata)
|
||||
train_df = self._coerce_non_timestamp_columns_to_numeric(train_df, params, metadata)
|
||||
|
||||
if len(train_df) <= 0:
|
||||
raise ValueError('Training data view is empty after transformation')
|
||||
|
||||
# Explicit validation dataset path
|
||||
train_data = pd.DataFrame(train_df[params.variable_columns + [params.target_variable]])
|
||||
if validation_file_bytes is not None:
|
||||
try:
|
||||
val_df = pd.read_csv(
|
||||
BytesIO(validation_file_bytes),
|
||||
sep=params.line_separator,
|
||||
decimal=params.decimal_separator,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
'Failed to load validation CSV data from MinIO object. '
|
||||
'Check file encoding, line separator and decimal separator.'
|
||||
) from exc
|
||||
|
||||
val_df = self._drop_rows_with_missing_timestamp(val_df, params, metadata)
|
||||
val_df = _ensure_date_column_parsed(val_df, params)
|
||||
val_df = self._configure_datetime_index(val_df, params, metadata)
|
||||
val_df = self._set_timezone_on_index(val_df, metadata)
|
||||
val_df = self._coerce_non_timestamp_columns_to_numeric(val_df, params, metadata)
|
||||
|
||||
if len(val_df) <= 0:
|
||||
raise ValueError('Validation data view is empty after transformation')
|
||||
|
||||
val_data = pd.DataFrame(val_df[params.variable_columns + [params.target_variable]])
|
||||
else:
|
||||
# Fallback path: derive validation via train/test split from a single dataset.
|
||||
train_data, val_data = train_test_split(
|
||||
train_data,
|
||||
train_size=params.train_size / 100,
|
||||
shuffle=params.shuffle,
|
||||
random_state=params.random_state,
|
||||
)
|
||||
|
||||
self.info(
|
||||
f'Data preprocessed and split successfully - experiment run id: {params.experiment_run_id}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
experiment_name = f'{params.experiment_name}'
|
||||
run_name = f'{experiment_name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
||||
|
||||
return TrainModelResult(
|
||||
params=params,
|
||||
train_data=train_data,
|
||||
val_data=val_data,
|
||||
run_name=run_name,
|
||||
experiment_name=experiment_name,
|
||||
)
|
||||
|
||||
def _as_series(self, pred: pd.DataFrame | pd.Series) -> pd.Series:
|
||||
if isinstance(pred, pd.Series):
|
||||
return pred
|
||||
# If the wrapper returns a single-column DataFrame, take its first column.
|
||||
if pred.shape[1] == 1:
|
||||
return pred.iloc[:, 0]
|
||||
raise ValueError('y_pred/y_train_pred must be a Series or single-column DataFrame')
|
||||
|
||||
def _extract_model_equation(self, regr: Any, params: TrainModelParams) -> dict:
|
||||
"""
|
||||
Extract the linear regression equation coefficients and create equation metadata.
|
||||
|
||||
This method extracts the coefficients and intercept from the trained model
|
||||
and creates a structured dictionary containing the equation information
|
||||
for serialization as JSON artifact.
|
||||
|
||||
Args:
|
||||
regr: Trained LinearRegressionModel object
|
||||
params: Training parameters containing variable information
|
||||
|
||||
Returns:
|
||||
dict: Equation metadata containing:
|
||||
- target_variable: Name of the target variable
|
||||
- coefficients: Dictionary mapping variable names to coefficients
|
||||
- intercept: Model intercept value
|
||||
- equation_string: Human-readable equation string
|
||||
- latex_equation: LaTeX formatted equation
|
||||
"""
|
||||
coefficients = regr.regr.coef_
|
||||
intercept = regr.regr.intercept_
|
||||
|
||||
# Get feature names - for polynomial models, use poly_feature_names
|
||||
model_kwargs = params.model_kwargs or {}
|
||||
degree = model_kwargs.get('degree', 1)
|
||||
poly_feature_names = model_kwargs.get('poly_feature_names', None)
|
||||
|
||||
if degree > 1 and poly_feature_names:
|
||||
feature_names = poly_feature_names
|
||||
else:
|
||||
feature_names = params.variable_columns
|
||||
|
||||
# Create coefficients dictionary
|
||||
coefficients_dict = {}
|
||||
for i, var in enumerate(feature_names):
|
||||
if i < len(coefficients):
|
||||
coefficients_dict[var] = float(coefficients[i])
|
||||
|
||||
# Create equation string
|
||||
equation_parts = [f'{coef:.6f} * {var}' for var, coef in coefficients_dict.items()]
|
||||
equation_string = f'{params.target_variable} = {intercept:.6f} + ' + ' + '.join(
|
||||
equation_parts
|
||||
)
|
||||
|
||||
# Create LaTeX equation
|
||||
latex_parts = [f'{coef:.6f} \\cdot {var}' for var, coef in coefficients_dict.items()]
|
||||
latex_equation = f'{params.target_variable} = {intercept:.6f} + ' + ' + '.join(latex_parts)
|
||||
|
||||
return {
|
||||
'target_variable': params.target_variable,
|
||||
'coefficients': coefficients_dict,
|
||||
'intercept': float(intercept),
|
||||
'equation_string': equation_string,
|
||||
'latex_equation': latex_equation,
|
||||
'model_type': params.model_name,
|
||||
'degree': degree,
|
||||
'interaction_only': model_kwargs.get('interaction_only', False),
|
||||
'original_features': feature_names,
|
||||
}
|
||||
|
||||
def compute_regression_metrics(
|
||||
self,
|
||||
tmr: TrainModelResult,
|
||||
wrapper: SientiaModel,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> TrainModelResult:
|
||||
"""
|
||||
Compute regression metrics for training results.
|
||||
|
||||
This helper mirrors the previous TrainingRepository.after_train_calculation
|
||||
behavior, assuming that predictions (y_pred/y_train_pred) are already on the
|
||||
correct scale for metric calculation (any scaling is handled inside the
|
||||
model wrapper).
|
||||
|
||||
Args:
|
||||
tmr: Training result containing:
|
||||
- train_data/val_data DataFrames with a target column
|
||||
- y_train_pred/y_pred populated (model predictions for train/val)
|
||||
wrapper: Trained model wrapper (used for linear equation extraction).
|
||||
metadata: Optional workflow metadata for debug logging.
|
||||
|
||||
Return:
|
||||
TrainModelResult: Same object with mse_val, mae_val and r2_val set.
|
||||
"""
|
||||
if tmr.y_pred is None:
|
||||
raise ValueError('y_pred must be set before computing regression metrics')
|
||||
|
||||
params = tmr.params
|
||||
target = params.target_variable
|
||||
|
||||
# True values are expected to come from val_data.
|
||||
y_true_val = tmr.val_data[target]
|
||||
|
||||
y_pred_val = self._as_series(tmr.y_pred).sort_index()
|
||||
y_true_val = y_true_val.sort_index()
|
||||
|
||||
# Align by index to avoid metric calculation errors if ordering differs.
|
||||
common_index = y_true_val.index.intersection(y_pred_val.index)
|
||||
head = min(5, len(y_true_val), len(y_pred_val))
|
||||
self.debug(
|
||||
'compute_regression_metrics index alignment: '
|
||||
f'val_n={len(y_true_val)} pred_n={len(y_pred_val)} common_n={len(common_index)}; '
|
||||
f'val_index_dtype={y_true_val.index.dtype} '
|
||||
f'pred_index_dtype={y_pred_val.index.dtype}; '
|
||||
f'val_index_sample={list(y_true_val.index[:head])} '
|
||||
f'pred_index_sample={list(y_pred_val.index[:head])}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if len(common_index) == 0:
|
||||
raise ValueError(
|
||||
'No overlapping indices between val_data and y_pred. '
|
||||
f'val_n={len(y_true_val)} pred_n={len(y_pred_val)} '
|
||||
f'val_index_sample={list(y_true_val.index[:head])} '
|
||||
f'pred_index_sample={list(y_pred_val.index[:head])}'
|
||||
)
|
||||
|
||||
y_true_val = y_true_val.loc[common_index]
|
||||
y_pred_val = y_pred_val.loc[common_index]
|
||||
|
||||
# Metrics helpers already round to 2 decimals.
|
||||
tmr.mse_val = mse(y_true_val, y_pred_val)
|
||||
tmr.mae_val = mae(y_true_val, y_pred_val)
|
||||
tmr.r2_val = r2(y_true_val, y_pred_val)
|
||||
|
||||
if params.model_type == 'linear_regression':
|
||||
inner = getattr(wrapper, 'model', None)
|
||||
regr = getattr(inner, 'regr', None) if inner is not None else None
|
||||
if regr is not None and hasattr(regr, 'coef_') and hasattr(regr, 'intercept_'):
|
||||
tmr.equation = self._extract_model_equation(inner, params)
|
||||
|
||||
return tmr
|
||||
|
||||
def _configure_datetime_index(
|
||||
self,
|
||||
data: pd.DataFrame | None,
|
||||
params: TrainModelParams,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Configure datetime index for the DataFrame.
|
||||
|
||||
Guards against None to avoid 'NoneType' object has no attribute 'index' downstream.
|
||||
Uses only params.date_column and assumes it was already parsed exactly once by
|
||||
_ensure_date_column_parsed.
|
||||
Args:
|
||||
data: The DataFrame to configure the datetime index for.
|
||||
params: The training parameters.
|
||||
metadata: The metadata for the training run.
|
||||
|
||||
Returns:
|
||||
The DataFrame with the datetime index configured.
|
||||
"""
|
||||
if data is None:
|
||||
raise ValueError(
|
||||
'Data is None after load_data. '
|
||||
'Check file format, line separator and decimal separator.'
|
||||
)
|
||||
|
||||
if params.date_column not in data.columns:
|
||||
raise ValueError(
|
||||
f'date_column "{params.date_column}" not found in dataset columns: {list(data.columns)}'
|
||||
)
|
||||
|
||||
if not pd.api.types.is_datetime64_any_dtype(data[params.date_column]):
|
||||
raise ValueError(
|
||||
f'date_column "{params.date_column}" must be datetime before index configuration'
|
||||
)
|
||||
|
||||
data = data.set_index(params.date_column)
|
||||
data = data.sort_index()
|
||||
self.info(f'Configured datetime index from column: {params.date_column}', metadata)
|
||||
return data
|
||||
|
||||
def _set_timezone_on_index(
|
||||
self, data: pd.DataFrame, metadata: dict[str, Any] | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Check if the index has a timezone and if not, set it to UTC timezone.
|
||||
|
||||
Args:
|
||||
data: The DataFrame to set the timezone on.
|
||||
metadata: The metadata for the training run.
|
||||
|
||||
Returns:
|
||||
The DataFrame with the timezone set.
|
||||
"""
|
||||
|
||||
if isinstance(data.index, pd.DatetimeIndex):
|
||||
if data.index.tz is None:
|
||||
data.index = data.index.tz_localize('UTC')
|
||||
else:
|
||||
data.index = data.index.tz_convert('UTC')
|
||||
else:
|
||||
raise ValueError('Index is not a DatetimeIndex')
|
||||
|
||||
return data
|
||||
|
||||
def _get_reports_directory(self) -> str:
|
||||
"""
|
||||
Get the absolute path to the reports directory.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the runtime reports root.
|
||||
"""
|
||||
return REPORTS_ROOT
|
||||
|
||||
def _create_run_directory(
|
||||
self, base_path: str, run_name: str, metadata: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Creates a directory inside the 'reports' folder with the run name and a timestamp.
|
||||
|
||||
Uses microsecond precision in timestamp to minimize collision probability
|
||||
in high-concurrency scenarios.
|
||||
|
||||
Args:
|
||||
base_path (str): The path to the 'reports' folder.
|
||||
run_name (str): The name of the run.
|
||||
|
||||
Returns:
|
||||
str: The path to the created directory.
|
||||
|
||||
Raises:
|
||||
PermissionError: If there are insufficient permissions to create the directory.
|
||||
OSError: If directory creation fails for any other reason.
|
||||
"""
|
||||
# Use microsecond precision to reduce collision probability
|
||||
run_dir = path.join(base_path, 'temp', f'{run_name}')
|
||||
|
||||
try:
|
||||
makedirs(run_dir, exist_ok=True)
|
||||
return run_dir
|
||||
except PermissionError as e:
|
||||
error_msg = f'Permission denied when creating directory: {run_dir}'
|
||||
self.error(error_msg, metadata)
|
||||
raise PermissionError(error_msg) from e
|
||||
except OSError as e:
|
||||
error_msg = f'Failed to create directory {run_dir}: {str(e)}'
|
||||
self.error(error_msg, metadata)
|
||||
raise OSError(error_msg) from e
|
||||
|
||||
def generate_report(
|
||||
self, data: TrainModelResult, metadata: dict[str, Any] | None = None
|
||||
) -> TrainModelResult:
|
||||
"""
|
||||
Generates a comprehensive report summarizing data quality, data drift, and regression analysis.
|
||||
|
||||
Args:
|
||||
reference_data (pd.DataFrame): The training dataset with predictions added.
|
||||
current_data (pd.DataFrame): The testing dataset with predictions added.
|
||||
data: The training model result containing the datasets, model, and parameters.
|
||||
|
||||
Returns:
|
||||
The updated result object with paths to the generated report and data files.
|
||||
|
||||
Raises:
|
||||
ValueError: If data conversion to float64 fails or DataFrames are invalid.
|
||||
PermissionError: If there are insufficient permissions to write files.
|
||||
OSError: If file writing fails for any other reason.
|
||||
"""
|
||||
|
||||
if data.run_name is None:
|
||||
raise ValueError('run_name is not set, cannot generate report')
|
||||
|
||||
if data.y_train_pred is None or data.y_pred is None:
|
||||
raise ValueError('y_train_pred or y_pred is not set, cannot generate report')
|
||||
|
||||
y_train_pred = data.y_train_pred.rename(columns={data.params.target_variable: 'prediction'})
|
||||
y_val_pred = data.y_pred.rename(columns={data.params.target_variable: 'prediction'})
|
||||
|
||||
# Join the predictions to the data
|
||||
reference_data = y_train_pred[['prediction']].join(data.train_data, how='inner')
|
||||
reference_data_float = reference_data.astype(np.float64)
|
||||
|
||||
current_data = y_val_pred[['prediction']].join(data.val_data, how='inner')
|
||||
current_data_float = current_data.astype(np.float64)
|
||||
|
||||
# Evidently's ConflictTargetMetric expects a literal `target` column name.
|
||||
# Keep the original target column and provide this alias for report metrics.
|
||||
target_col = data.params.target_variable
|
||||
|
||||
reference_data_float['target'] = reference_data_float[target_col]
|
||||
current_data_float['target'] = current_data_float[target_col]
|
||||
|
||||
# Initialize report generator
|
||||
base_path = self._get_reports_directory()
|
||||
data.run_dir = self._create_run_directory(base_path, data.run_name)
|
||||
|
||||
# Template path is the code path of the model_manager package
|
||||
template_path = path.join(PROJECT_BASE_PATH, 'reports')
|
||||
|
||||
report = Reports(
|
||||
reference_data=reference_data_float,
|
||||
current_data=current_data_float,
|
||||
base_path=data.run_dir,
|
||||
template_path=template_path,
|
||||
target_name=data.params.target_variable,
|
||||
)
|
||||
|
||||
# Generate report sections
|
||||
feature_and_target_cols = data.params.variable_columns + [target_col]
|
||||
report.add_data_quality_section(columns=feature_and_target_cols)
|
||||
report.add_data_drift_section(columns=feature_and_target_cols)
|
||||
report.add_regression_section()
|
||||
|
||||
# Save HTML report
|
||||
data.report_path = path.join(data.run_dir, 'report.html')
|
||||
report.save_all_sections_html(data.report_path)
|
||||
|
||||
# Save training / validation CSVs using the same frames as the report (includes
|
||||
# literal `target` alias for Evidently, plus predictions and float-cast features).
|
||||
data.train_data_path = path.join(data.run_dir, 'train_data.csv')
|
||||
reference_data_float.to_csv(data.train_data_path, index=False)
|
||||
|
||||
# Save test data CSV
|
||||
data.test_data_path = path.join(data.run_dir, 'test_data.csv')
|
||||
current_data_float.to_csv(data.test_data_path, index=False)
|
||||
|
||||
# Save equation as JSON
|
||||
if data.equation is not None and data.params.model_type == 'linear_regression':
|
||||
data.equation_path = path.join(data.run_dir, 'model_equation.json')
|
||||
with open(data.equation_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data.equation, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return data
|
||||
|
||||
def cleanup_run_directory(self, run_dir: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
"""
|
||||
Clean up temporary run directory after model training.
|
||||
|
||||
This activity deletes the temporary directory created during model training
|
||||
and artifact generation. It implements idempotent cleanup to handle cases
|
||||
where the directory may have already been deleted.
|
||||
|
||||
Args:
|
||||
run_dir (str): Path to the run directory to delete
|
||||
"""
|
||||
if not run_dir:
|
||||
self.info('No run directory specified, skipping cleanup')
|
||||
return
|
||||
|
||||
if path.exists(run_dir):
|
||||
rmtree(run_dir)
|
||||
self.info(f'Run directory deleted successfully: {run_dir}')
|
||||
else:
|
||||
self.info(f'Run directory already deleted: {run_dir}')
|
||||
Reference in New Issue
Block a user