Code import - branch release/SIENTIAPDE-1645

This commit is contained in:
2026-08-05 13:53:37 +00:00
commit d481e0acff
116 changed files with 92848 additions and 0 deletions

View File

View 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'),
}

View 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

View 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',
]

View 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'

View 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)

View 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

View 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}')