feat: integrate PluginStore and MinIO repository into model manager activities

- Added PluginStore integration for model management.
- Replaced StorageRepository with MinIORepository in Activities, Cleanup, and Training classes.
- Updated training logic to handle validation files and improved data management.
- Enhanced configuration for MinIO and PluginStore in connectors.
- Removed deprecated model repository and storage repository files.
- Updated environment variable handling for new configurations.
This commit is contained in:
vitor-aignosi
2026-03-11 17:35:05 -03:00
parent 9d71c0cf80
commit cf5111e520
23 changed files with 1480 additions and 4588 deletions

View File

@@ -0,0 +1,381 @@
"""
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.
"""
from io import BytesIO
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_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
from model_manager.sientia.metrics import mae, mse, r2
from model_manager.sientia.utils import split_train_test
from model_manager.utils.models.train_model_params import TrainModelParams
from model_manager.utils.models.train_model_result import TrainModelResult
def _ensure_date_column_parsed(data: pd.DataFrame, params: TrainModelParams) -> pd.DataFrame:
"""
If date_column is set, parse the column as timezone-aware
datetime to avoid comparison errors downstream.
The values are expected to follow the global DATETIME_FORMAT_WITH_TZ
pattern defined in sientia_do.temporal.constants.
"""
if not params.date_column or params.date_column not in data.columns:
return data
try:
data = data.copy()
parsed = pd.to_datetime(
data[params.date_column],
format=DATETIME_FORMAT_WITH_TZ,
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
def _single_variable_support_mask(
data_view: pd.DataFrame,
var_col: str,
target_variable: str,
config: dict,
) -> np.ndarray | None:
"""Compute keep mask for one variable's support lines; None if config is invalid or skipped."""
if var_col not in data_view.columns:
return None
upper = config.get('upper_line') or config.get('upperLine')
lower = config.get('lower_line') or config.get('lowerLine')
if not upper or not lower:
return None
x_vals = data_view[var_col].astype(float).to_numpy()
y_vals = data_view[target_variable].astype(float).to_numpy()
xmin, xmax = float(np.nanmin(x_vals)), float(np.nanmax(x_vals))
ymin, ymax = float(np.nanmin(y_vals)), float(np.nanmax(y_vals))
x_range = (xmax - xmin) if (xmax - xmin) != 0 else 1.0
y_range = (ymax - ymin) if (ymax - ymin) != 0 else 1.0
scale_ratio = y_range / x_range
b1 = float(upper.get('intercept', 0))
deg1 = float(upper.get('angle', 0))
b2 = float(lower.get('intercept', 0))
deg2 = float(lower.get('angle', 0))
m1 = np.tan(np.deg2rad(deg1)) * scale_ratio
m2 = np.tan(np.deg2rad(deg2)) * scale_ratio
y1 = m1 * x_vals + b1
y2 = m2 * x_vals + b2
lower_bound = np.minimum(y1, y2)
upper_bound = np.maximum(y1, y2)
return (y_vals >= lower_bound) & (y_vals <= upper_bound)
def _apply_support_filters(
data_view: pd.DataFrame,
target_variable: str,
support_filters: dict,
) -> pd.DataFrame:
"""
Keep only rows where (var, target) lies between the two guide lines for each variable.
For each variable in support_filters, the condition is lower(x_var) <= target <= upper(x_var),
where lower/upper are the two lines (intercept + slope from angle, scaled by y_range/x_range).
Global mask is AND across all variables. Matches DEMO logic in template_01.py.
Args:
data_view: DataFrame after preprocessor transform.
target_variable: Name of the target column (y axis).
support_filters: Per-variable config with upper_line/lower_line, each {intercept, angle}.
Returns:
data_view filtered to rows satisfying all variable conditions; unchanged if support_filters empty.
"""
if not support_filters or target_variable not in data_view.columns:
return data_view
combined_keep_mask = np.ones(len(data_view), dtype=bool)
n = len(data_view)
for var_col, config in support_filters.items():
keep_mask = _single_variable_support_mask(data_view, var_col, target_variable, config)
if keep_mask is not None and len(keep_mask) == n:
combined_keep_mask &= keep_mask
return data_view.loc[combined_keep_mask]
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 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 = _ensure_date_column_parsed(train_df, params)
train_df = self._configure_datetime_index(train_df, params, metadata)
if params.support_filters:
train_df = _apply_support_filters(
train_df,
params.target_variable,
params.support_filters,
)
if len(train_df) <= 0:
raise ValueError('Training data view is empty after transformation')
# Explicit validation dataset path
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 = _ensure_date_column_parsed(val_df, params)
val_df = self._configure_datetime_index(val_df, params, metadata)
if params.support_filters:
val_df = _apply_support_filters(
val_df,
params.target_variable,
params.support_filters,
)
if len(val_df) <= 0:
raise ValueError('Validation data view is empty after transformation')
x_train = pd.DataFrame(train_df[params.variable_columns])
y_train = pd.Series(train_df[params.target_variable])
x_test = pd.DataFrame(val_df[params.variable_columns])
y_test = pd.Series(val_df[params.target_variable])
else:
# Fallback path: derive validation via train/test split from a single dataset.
x_train, x_test, y_train, y_test = split_train_test(
pd.DataFrame(train_df[params.variable_columns]),
pd.Series(train_df[params.target_variable]),
train_size=params.train_size / 100,
shuffle=params.shuffle,
random_state=42,
)
self.info(
f'Data preprocessed and split successfully - experiment run id: {params.experiment_run_id}',
metadata,
)
return TrainModelResult(
params=params,
x_train=x_train,
x_test=x_test,
y_train=y_train,
y_test=y_test,
)
def compute_regression_metrics(
self,
params: TrainModelParams,
tmr: TrainModelResult,
) -> TrainModelResult:
"""
Compute regression metrics for training results.
This helper mirrors the previous TrainingRepository.after_train_calculation
behavior, assuming that y_pred/y_train_pred are already on the correct scale
for metric calculation (any scaling is handled inside the model wrapper).
Args:
params: Training parameters used during model training.
tmr: Training result with y_train, y_test, y_train_pred and y_pred populated.
Return:
Updated TrainModelResult with mse_val, mae_val and r2_val fields populated.
"""
del params # unused for now, kept for possible future extensions
tmr.x_train = tmr.x_train.sort_index()
tmr.x_test = tmr.x_test.sort_index()
tmr.y_train = tmr.y_train.sort_index()
tmr.y_test = tmr.y_test.sort_index()
if tmr.y_pred is not None:
tmr.y_pred = tmr.y_pred.sort_index()
if tmr.y_train_pred is not None:
tmr.y_train_pred = tmr.y_train_pred.sort_index()
assert tmr.y_pred is not None, 'y_pred should be set at this point'
tmr.mse_val = round(
mse(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
2,
)
tmr.mae_val = round(
mae(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
2,
)
tmr.r2_val = round(
r2(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
2,
)
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.
Prefers params.date_column when set; otherwise looks for common timestamp column names.
"""
if data is None:
raise ValueError(
'Data is None after load_data. '
'Check file format, line separator and decimal separator.'
)
if not isinstance(data, pd.DataFrame):
raise TypeError(f'Expected DataFrame, got {type(data).__name__}')
if isinstance(data.index, pd.DatetimeIndex):
self.info('DataFrame already has DatetimeIndex', metadata)
return data.sort_index()
common_timestamp_columns = [
'timestamp',
'Timestamp',
'TIMESTAMP',
'date',
'Date',
'DATE',
'DATA',
'datetime',
'DateTime',
]
timestamp_columns = ([params.date_column] if params.date_column else []) + [
c for c in common_timestamp_columns if c != params.date_column
]
for col in timestamp_columns:
if col in data.columns:
try:
data[col] = pd.to_datetime(data[col])
data = data.set_index(col)
data = data.sort_index()
self.info(f'Configured datetime index from column: {col}', metadata)
return data
except (ValueError, TypeError) as e:
self.warning(f'Failed to convert column {col} to datetime: {e}', metadata)
continue
# If no timestamp column found, check if first column looks like a timestamp
first_col = data.columns[0]
try:
# Try to parse first column as datetime
test_values = data[first_col].head(10).dropna()
if len(test_values) > 0:
pd.to_datetime(test_values)
data[first_col] = pd.to_datetime(data[first_col])
data = data.set_index(first_col)
data = data.sort_index()
self.info(f'Configured datetime index from first column: {first_col}', metadata)
return data
except (ValueError, TypeError):
pass
self.warning(
'No timestamp column found - some features may not work correctly',
metadata,
)
return data

View File

@@ -1,471 +0,0 @@
"""
MLFlow Repository
This module contains the MLFlowRepository class, which is responsible for
handling model training artifacts and MLFlow operations for the Model Manager system.
It includes methods for generating training reports, managing artifacts,
and logging model runs to MLFlow.
"""
import json
import os
import shutil
import warnings
from datetime import datetime
from os import makedirs, path
import numpy as np
import pandas as pd
from sientia_do.observability.logger import Logger
from model_manager.sientia.model_serving import ModelServing # type: ignore[import-untyped]
from model_manager.sientia.reports import Reports # type: ignore[import-untyped]
from model_manager.utils.models.train_model_result import TrainModelResult
# Suppress sklearn FutureWarning about 'squared' deprecation without changing business logic
warnings.filterwarnings('ignore', category=FutureWarning, message=".*'squared' is deprecated.*")
class ModelRepository:
def __init__(self, url, username, password, logger: Logger):
self.model_serving = ModelServing(tracking_uri=url, username=username, password=password)
self.logger = logger
self.logger.info(f'MLFlow client initialized at {url}')
def save_model(self, train_result: TrainModelResult) -> TrainModelResult:
"""
Save a trained ML model and its artifacts to MLflow.
This activity orchestrates the complete model saving pipeline:
1. Generates the next run name for the experiment
2. Creates and organizes artifacts (reports, data files)
3. Logs model, parameters, metrics, and artifacts to MLflow
Args:
input_data: Configuration for model saving operation
Required keys:
- metadata (dict): Workflow execution metadata
- train_result (TrainModelResult): Training result with model and metrics
Returns:
TrainModelResult: Updated training result with run_name and artifacts
Raises:
Exception: If model saving fails (after sending notification)
"""
experiment_name = train_result.params.experiment_name
train_result.run_name = self._get_next_run_name(experiment_name)
train_result = self._generate_artifacts(train_result)
self._save_run(train_result)
self.logger.info(
f'Model saved successfully - experiment run id: {train_result.params.experiment_run_id}, '
f'experiment name: {experiment_name}, '
f'run name: {train_result.run_name}'
)
return train_result
def cleanup_run_directory(self, run_dir: str) -> 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.logger.info('No run directory specified, skipping cleanup')
return
if os.path.exists(run_dir):
shutil.rmtree(run_dir)
self.logger.info(f'Run directory deleted successfully: {run_dir}')
else:
self.logger.info(f'Run directory already deleted: {run_dir}')
def _get_next_run_name(self, experiment_name: str) -> str:
"""
Generates the next run name for a given experiment.
Args:
experiment_name (str): The name of the experiment for which the next run name is being generated.
Returns:
str: A unique run name in the format "<experiment_name>-<next_run_number>".
"""
runs = self.model_serving.search_runs_by_name(
experiment_names=[experiment_name], order_by=['start_time desc']
)
next_run_number = len(runs) + 1
return f'{experiment_name}-{next_run_number}'
def _generate_artifacts(self, data: TrainModelResult) -> TrainModelResult:
"""
Generates and organizes artifacts related to the training process, such as reports and data files.
Args:
data: The training model result containing the datasets, model, and parameters.
Returns:
The updated result object with paths to the generated artifacts.
Raises:
FileNotFoundError: If the reports directory or header.html file does not exist.
ValueError: If run_name is not set.
"""
# Validate that run_name is set
if not data.run_name:
error_msg = 'run_name must be set before generating artifacts'
self.logger.error(error_msg)
raise ValueError(error_msg)
reference_data, current_data = self._init_artifacts_data(data)
base_path = self._get_reports_directory()
# Validate that reports directory exists
if not path.exists(base_path):
error_msg = f'Reports directory does not exist: {base_path}'
self.logger.error(error_msg)
raise FileNotFoundError(error_msg)
data.run_dir = self._create_run_directory(base_path, data.run_name)
header_file_path = path.join(base_path, 'header.html')
# Validate that header.html exists
if not path.exists(header_file_path):
error_msg = f'Header file does not exist: {header_file_path}'
self.logger.error(error_msg)
raise FileNotFoundError(error_msg)
self._setup_run_directory(data.run_dir, header_file_path)
return self._generate_report(reference_data, current_data, data)
def _save_run(self, data: TrainModelResult):
"""
Logs the details of a machine learning run, including parameters, metrics, models, and artifacts,
to the Sientia tracking system.
Args:
data: The training model result containing the datasets, model, parameters,
and evaluation metrics.
Raises:
ValueError: If required metrics or artifacts are missing.
Exception: If MLflow logging fails for any reason.
"""
# Validate that required artifacts exist before attempting to log
if not data.report_path or not path.exists(data.report_path):
error_msg = f'Report file does not exist: {data.report_path}'
self.logger.error(error_msg)
raise ValueError(error_msg)
if not data.train_data_path or not path.exists(data.train_data_path):
error_msg = f'Training data file does not exist: {data.train_data_path}'
self.logger.error(error_msg)
raise ValueError(error_msg)
if not data.test_data_path or not path.exists(data.test_data_path):
error_msg = f'Test data file does not exist: {data.test_data_path}'
self.logger.error(error_msg)
raise ValueError(error_msg)
# Validate that metrics are present
if data.mse_val is None or data.r2_val is None or data.mae_val is None:
error_msg = 'One or more metrics (MSE, R2, MAE) are None'
self.logger.error(error_msg)
raise ValueError(error_msg)
# Prepare parameters
interval_strs = [
(str(interval[0]), str(interval[1]))
for interval in (data.params.removed_intervals or [])
]
# Set experiment and create run
self.model_serving.set_experiment(data.params.experiment_name)
with self.model_serving.save_experiment(
run_name=data.run_name, description=data.params.experiment_name
):
# Log model parameters
self.model_serving.log_param('model_name', data.params.model_name)
self.model_serving.log_param(
'models_params',
{'degree': data.params.degree, 'interaction_only': data.params.interaction_only},
)
self.model_serving.log_param('target_variable', data.params.target_variable)
self.model_serving.log_param('input_variables', data.params.variable_columns)
self.model_serving.log_param('nan_treatment', data.params.nan_treatment)
self.model_serving.log_param('lag_train', data.params.lag_train)
self.model_serving.log_param('lag_transform', data.params.lag_val)
static_threshold_value = None
if data.params.rem_static_win:
static_threshold_value = (
data.params.static_threshold if data.params.static_threshold is not None else 1
)
self.model_serving.log_param('static_threshold', static_threshold_value)
self.model_serving.log_param('lower_limits', data.params.low_lim)
self.model_serving.log_param('upper_limits', data.params.upp_lim)
self.model_serving.log_param('scaler_name', data.params.scaler_name)
self.model_serving.log_param('scaler_params', data.scaler_dict)
self.model_serving.log_param('include_ar', data.params.include_ar)
self.model_serving.log_param('train_size', round(data.params.train_size / 100, 2))
self.model_serving.log_param('test_size', round(1 - (data.params.train_size / 100), 2))
self.model_serving.log_param('start_date', data.params.start_date)
self.model_serving.log_param('end_date', data.params.end_date)
self.model_serving.log_param('removed_intervals', interval_strs)
self.model_serving.log_param('retrain', False)
self.model_serving.log_param('support_filters', data.params.support_filters)
# Log evaluation metrics
self.model_serving.log_metric('MSE', data.mse_val)
self.model_serving.log_metric('R2', data.r2_val)
self.model_serving.log_metric('MAE', data.mae_val)
# Log models
self.model_serving.log_model(data.process_data, 'data_model')
self.model_serving.log_model(data.regr, 'prediction_model')
# Log artifacts
self.model_serving.log_artifact(data.report_path)
self.model_serving.log_artifact(data.train_data_path)
self.model_serving.log_artifact(data.test_data_path)
# Log equation artifact if available
if data.equation_path and path.exists(data.equation_path):
self.model_serving.log_artifact(data.equation_path)
def _init_artifacts_data(self, data: TrainModelResult) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Prepares the reference and current datasets for artifact generation.
Args:
data: The training model result containing the datasets and model.
Returns:
tuple: A tuple containing:
- reference_data: The training dataset with predictions added.
- current_data: The testing dataset with predictions added.
Raises:
ValueError: If training or test datasets are empty or invalid.
AttributeError: If required attributes are missing from the data object.
"""
# Validate that required DataFrames are not empty
# Note: x_train, y_train, x_test, y_test, and regr are required fields in TrainModelResult
# so we only check if they are empty, not None
if data.x_train.empty:
error_msg = 'Training features (x_train) are empty'
self.logger.error(error_msg)
raise ValueError(error_msg)
if data.y_train.empty:
error_msg = 'Training target (y_train) is empty'
self.logger.error(error_msg)
raise ValueError(error_msg)
if data.x_test.empty:
error_msg = 'Test features (x_test) are empty'
self.logger.error(error_msg)
raise ValueError(error_msg)
if data.y_test.empty:
error_msg = 'Test target (y_test) is empty'
self.logger.error(error_msg)
raise ValueError(error_msg)
# Validate that predictions exist (y_pred is optional, so check for None)
if data.y_pred is None:
error_msg = 'Test predictions (y_pred) are None'
self.logger.error(error_msg)
raise ValueError(error_msg)
if data.y_train_pred is None:
error_msg = 'Training predictions (y_train_pred) are None'
self.logger.error(error_msg)
raise ValueError(error_msg)
# Prepare reference data (training set)
reference_data = pd.concat([data.x_train, data.y_train], axis=1)
reference_data = reference_data.rename(columns={data.params.target_variable: 'target'})
# Use pre-calculated predictions (calculated before denormalization to avoid overflow)
reference_data['prediction'] = data.y_train_pred
# Prepare current data (test set)
current_data = pd.concat([data.x_test, data.y_test], axis=1)
current_data = current_data.rename(columns={data.params.target_variable: 'target'})
current_data['prediction'] = data.y_pred
return reference_data, current_data
def _create_run_directory(self, base_path: str, run_name: str) -> 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
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
run_dir = path.join(base_path, 'temp', f'{run_name}_{timestamp}')
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.logger.error(error_msg)
raise PermissionError(error_msg) from e
except OSError as e:
error_msg = f'Failed to create directory {run_dir}: {str(e)}'
self.logger.error(error_msg)
raise OSError(error_msg) from e
def _setup_run_directory(self, run_dir: str, header_file_path: str):
"""
Creates empty files and copies a header file into the specified run directory.
Note: Lock removed as each run has its own unique directory, so no synchronization
is needed between different runs. File operations within the same directory are
atomic at the OS level.
Args:
run_dir (str): The path to the run directory where the files will be created.
header_file_path (str): The path to the header.html file to be copied.
Raises:
FileNotFoundError: If the header file does not exist.
PermissionError: If there are insufficient permissions to create files.
OSError: If file creation or copying fails for any other reason.
"""
empty_files = ['data_drift.html', 'data_quality.html', 'regression.html']
try:
# Create empty placeholder files
for file_name in empty_files:
file_path = path.join(run_dir, file_name)
with open(file_path, 'w'):
pass # Create empty file
# Copy header file to run directory
header_dest = path.join(run_dir, 'header.html')
shutil.copy(header_file_path, header_dest)
except FileNotFoundError as e:
error_msg = f'Header file not found: {header_file_path}'
self.logger.error(error_msg)
raise FileNotFoundError(error_msg) from e
except PermissionError as e:
error_msg = f'Permission denied when setting up directory: {run_dir}'
self.logger.error(error_msg)
raise PermissionError(error_msg) from e
except OSError as e:
error_msg = f'Failed to setup run directory {run_dir}: {str(e)}'
self.logger.error(error_msg)
raise OSError(error_msg) from e
def _generate_report(
self, reference_data: pd.DataFrame, current_data: pd.DataFrame, data: TrainModelResult
) -> 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.
"""
try:
# Convert data to float64 for report generation
# This may raise ValueError if data contains non-numeric values
reference_data_float = reference_data.astype(np.float64)
current_data_float = current_data.astype(np.float64)
# Initialize report generator
report = Reports(
reference_data=reference_data_float,
current_data=current_data_float,
base_path=data.run_dir,
)
# Generate report sections
report.add_data_quality_section(columns=data.params.variable_columns + ['target'])
report.add_data_drift_section(columns=data.params.variable_columns + ['target'])
report.add_regression_section()
# Validate that run_dir is set (should be set by _create_run_directory)
if not data.run_dir:
error_msg = 'run_dir is not set after directory creation'
self.logger.error(error_msg)
raise ValueError(error_msg)
# Save HTML report
data.report_path = path.join(data.run_dir, 'report.html')
report.save_all_sections_html(data.report_path)
# Save training data CSV
data.train_data_path = path.join(data.run_dir, 'train_data.csv')
reference_data.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.to_csv(data.test_data_path, index=False)
# Save equation as JSON
if data.equation is not None:
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
except ValueError as e:
error_msg = f'Failed to convert data to float64 for report generation: {str(e)}'
self.logger.error(error_msg)
raise ValueError(error_msg) from e
except PermissionError as e:
error_msg = f'Permission denied when writing report files to: {data.run_dir}'
self.logger.error(error_msg)
raise PermissionError(error_msg) from e
except OSError as e:
error_msg = f'Failed to generate report in {data.run_dir}: {str(e)}'
self.logger.error(error_msg)
raise OSError(error_msg) from e
def _get_reports_directory(self) -> str:
"""
Get the absolute path to the reports directory.
Returns:
str: Absolute path to model_manager/reports directory.
"""
# Get the directory where this file is located (model_manager/utils/repository/)
current_file_dir = path.dirname(path.abspath(__file__))
# Navigate up to model_manager/ and then to reports/
model_manager_dir = path.dirname(path.dirname(current_file_dir))
reports_dir = path.join(model_manager_dir, 'reports')
return reports_dir

View File

@@ -1,164 +0,0 @@
from io import BytesIO
import boto3 # type: ignore[import-untyped]
from botocore.config import Config # type: ignore[import-untyped]
from sientia_do.observability.logger import Logger
class StorageRepository:
"""
MinIO (S3-compatible) storage activities for file operations.
This class provides activities for interacting with MinIO object storage,
including file download and deletion operations. It handles authentication,
connection management, and comprehensive error handling.
The class implements best practices for S3/MinIO operations:
- Connection reuse (boto3 client is thread-safe)
- Automatic retry with exponential backoff
- Comprehensive error handling and logging
- Notification integration for critical errors
Attributes:
endpoint_url (str): MinIO server endpoint URL
access_key (str): MinIO access key ID
secret_key (str): MinIO secret access key
region (str): MinIO region name
use_ssl (bool): Whether to use SSL/TLS for connections
minio_client: Boto3 S3 client configured for MinIO
"""
def __init__(
self,
endpoint_url: str,
access_key: str,
secret_key: str,
region: str,
use_ssl: bool,
max_retry_attempts: int,
retry_mode: str,
connect_timeout: int,
read_timeout: int,
logger: Logger,
):
"""
Initialize a reusable MinIO client with retry configuration.
Args:
endpoint_url: MinIO server endpoint URL (e.g., http://localhost:9000).
access_key: MinIO access key ID for authentication.
secret_key: MinIO secret access key for authentication.
region: MinIO region name (e.g., us-east-1).
use_ssl: Whether to use SSL/TLS for connections.
max_retry_attempts: Maximum number of retry attempts (e.g., 3).
retry_mode: Retry policy to apply (standard, legacy, adaptive).
connect_timeout: Connection timeout in seconds.
read_timeout: Read timeout in seconds.
logger: Logger used for observability.
"""
self.endpoint_url = endpoint_url
self.access_key = access_key
self.secret_key = secret_key
self.region = region
self.use_ssl = use_ssl
self.max_retry_attempts = max_retry_attempts
self.retry_mode = retry_mode
self.connect_timeout = connect_timeout
self.read_timeout = read_timeout
self.logger = logger
boto_config = Config(
region_name=region,
retries={
'max_attempts': max_retry_attempts,
'mode': retry_mode,
},
connect_timeout=connect_timeout,
read_timeout=read_timeout,
)
self.minio_client = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=boto_config,
use_ssl=use_ssl,
)
self.logger.info(f'MinIO client initialized at {endpoint_url}')
def close(self) -> None:
self.minio_client.close()
self.logger.info('MinIO client closed')
def fetch_file(self, bucket_name: str, file_name: str) -> BytesIO:
"""
Fetch an object from MinIO and return its contents as `BytesIO`.
Args:
bucket_name: MinIO bucket where the object resides.
file_name: Object key to download inside the bucket.
Returns:
BytesIO: File-like stream containing the downloaded bytes.
Raises:
OSError: If the download fails (network, permissions, missing key, etc.).
"""
response = self.minio_client.get_object(Bucket=bucket_name, Key=file_name)
with response['Body'] as body:
file_content = body.read()
file_size = len(file_content)
self.logger.info(
f'File fetched successfully: {bucket_name}/{file_name} ({file_size} bytes)'
)
return BytesIO(file_content)
def delete_file(self, bucket_name: str, file_name: str) -> None:
"""
Remove an object from MinIO storage.
Args:
bucket_name: Bucket that contains the object.
file_name: Object key to delete.
"""
self.minio_client.delete_object(Bucket=bucket_name, Key=file_name)
self.logger.info(f'File deleted successfully: {bucket_name}/{file_name}')
def list_bucket_objects(self, bucket_name: str, max_keys: int = 1000) -> list[str]:
"""
List objects in a MinIO bucket.
This method uses the MinIO/S3 list_objects_v2 API to retrieve objects
from the specified bucket. This is optimized for cleanup operations
by using configurable pagination.
Args:
bucket_name: Name of the bucket to list objects from.
max_keys: Maximum number of keys per page (default: 1000).
Returns:
List[str]: List of object keys (file names).
"""
# Use list_objects_v2 for efficient pagination
paginator = self.minio_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=bucket_name, MaxKeys=max_keys)
objects = []
total_count = 0
for page in pages:
if 'Contents' in page:
for obj in page['Contents']:
objects.append(obj['Key'])
total_count += 1
self.logger.info(f'Listed {total_count} objects from bucket {bucket_name}')
return objects

View File

@@ -1,519 +0,0 @@
"""
Training repository for ML model training operations.
This module provides the core training logic for machine learning models,
including data preprocessing, model training, and post-training calculations.
Migrated from laborious/utils/train_model_utils.py.
"""
from io import BytesIO
import numpy as np
import pandas as pd
from sientia_do.observability.logger import Logger
from sientia_do.operations.df_preprocessor import load_data
from sientia_do.operations.normalization import MinMaxScaler, Z_Scaler
from model_manager.sientia.metrics import mae, mse, r2
from model_manager.sientia.models import (
DataPreprocessor,
LinearRegressionModel,
)
from model_manager.sientia.models import (
_frontend_date_format_to_strftime as _frontend_format_to_strftime,
)
from model_manager.sientia.utils import split_train_test
from model_manager.utils.models.train_model_params import TrainModelParams
from model_manager.utils.models.train_model_result import TrainModelResult
def _ensure_date_column_parsed(data: pd.DataFrame, params: TrainModelParams) -> pd.DataFrame:
"""If date_column and date_format are set, parse the column as datetime to avoid comparison errors downstream."""
if not params.date_column or not params.date_format or params.date_column not in data.columns:
return data
try:
python_fmt = _frontend_format_to_strftime(params.date_format)
data = data.copy()
data[params.date_column] = pd.to_datetime(
data[params.date_column], format=python_fmt, errors='coerce'
)
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
def _single_variable_support_mask(
data_view: pd.DataFrame,
var_col: str,
target_variable: str,
config: dict,
) -> np.ndarray | None:
"""Compute keep mask for one variable's support lines; None if config is invalid or skipped."""
if var_col not in data_view.columns:
return None
upper = config.get('upper_line') or config.get('upperLine')
lower = config.get('lower_line') or config.get('lowerLine')
if not upper or not lower:
return None
x_vals = data_view[var_col].astype(float).to_numpy()
y_vals = data_view[target_variable].astype(float).to_numpy()
xmin, xmax = float(np.nanmin(x_vals)), float(np.nanmax(x_vals))
ymin, ymax = float(np.nanmin(y_vals)), float(np.nanmax(y_vals))
x_range = (xmax - xmin) if (xmax - xmin) != 0 else 1.0
y_range = (ymax - ymin) if (ymax - ymin) != 0 else 1.0
scale_ratio = y_range / x_range
b1 = float(upper.get('intercept', 0))
deg1 = float(upper.get('angle', 0))
b2 = float(lower.get('intercept', 0))
deg2 = float(lower.get('angle', 0))
m1 = np.tan(np.deg2rad(deg1)) * scale_ratio
m2 = np.tan(np.deg2rad(deg2)) * scale_ratio
y1 = m1 * x_vals + b1
y2 = m2 * x_vals + b2
lower_bound = np.minimum(y1, y2)
upper_bound = np.maximum(y1, y2)
return (y_vals >= lower_bound) & (y_vals <= upper_bound)
def _apply_support_filters(
data_view: pd.DataFrame,
target_variable: str,
support_filters: dict,
) -> pd.DataFrame:
"""
Keep only rows where (var, target) lies between the two guide lines for each variable.
For each variable in support_filters, the condition is lower(x_var) <= target <= upper(x_var),
where lower/upper are the two lines (intercept + slope from angle, scaled by y_range/x_range).
Global mask is AND across all variables. Matches DEMO logic in template_01.py.
Args:
data_view: DataFrame after preprocessor transform.
target_variable: Name of the target column (y axis).
support_filters: Per-variable config with upper_line/lower_line, each {intercept, angle}.
Returns:
data_view filtered to rows satisfying all variable conditions; unchanged if support_filters empty.
"""
if not support_filters or target_variable not in data_view.columns:
return data_view
combined_keep_mask = np.ones(len(data_view), dtype=bool)
n = len(data_view)
for var_col, config in support_filters.items():
keep_mask = _single_variable_support_mask(data_view, var_col, target_variable, config)
if keep_mask is not None and len(keep_mask) == n:
combined_keep_mask &= keep_mask
return data_view.loc[combined_keep_mask]
class TrainingRepository:
"""
Repository for machine learning model training operations.
This class encapsulates the core logic for training ML models, migrated from
laborious/utils/train_model_utils.py. Follows the same pattern as MLFlowRepository
with instance methods and logger integration.
Attributes:
logger (Logger): Logger instance for observability and debugging
"""
def __init__(self, logger: Logger):
"""
Initialize TrainingRepository with logger.
Args:
logger: Logger instance for observability
"""
self.logger = logger
def train(self, uploaded_file: BytesIO, params: TrainModelParams) -> TrainModelResult:
"""
Train a machine learning model using the provided file and parameters.
This method orchestrates the training pipeline:
1. Load data from BytesIO file
2. Initialize and fit data preprocessor
3. Transform data and validate
4. Split into train/test sets
5. Initialize scaler dictionary
6. Train LinearRegression model
Args:
uploaded_file: BytesIO object containing training data (CSV format)
params: Training parameters (TrainModelParams)
Returns:
TrainModelResult: Object containing trained model, processed data,
train/test splits, and scaler dictionary
Raises:
ValueError: If transformed data is empty
Exception: If data loading, preprocessing, or training fails
"""
data = load_data(uploaded_file, params.line_separator, params.decimal_separator)
if data is None:
raise ValueError(
'Failed to load CSV data: load_data returned None. '
'Check file encoding, line separator and decimal separator.'
)
data = _ensure_date_column_parsed(data, params)
data = self._configure_datetime_index(data, params)
process_data = self._init_data_preprocessor(params)
process_data.fit(data)
data_view = process_data.transform(data)
if params.support_filters:
data_view = _apply_support_filters(
data_view,
params.target_variable,
params.support_filters,
)
if len(data_view) <= 0:
raise ValueError('Data view is empty after transformation')
x_train, x_test, y_train, y_test = split_train_test(
data_view[params.variable_columns],
data_view[params.target_variable],
train_size=params.train_size / 100,
shuffle=params.shuffle,
random_state=42,
)
data_train = pd.concat([x_train, y_train], axis=1)
scaler_dict = self._init_scaler_dict(process_data, params)
regr = LinearRegressionModel(
target_variable=params.target_variable,
variable_columns=params.variable_columns,
degree=params.degree,
interaction_only=params.interaction_only,
)
regr.fit(data_train)
self.logger.info(
f'Model trained successfully - experiment run id: {params.experiment_run_id}'
)
return TrainModelResult(
params=params,
process_data=process_data,
x_train=x_train,
x_test=x_test,
y_train=y_train,
y_test=y_test,
regr=regr,
scaler_dict=scaler_dict,
)
def after_train_calculation(
self, params: TrainModelParams, tmr: TrainModelResult
) -> TrainModelResult:
"""
Perform post-training calculations: predictions, denormalization, and metrics.
This method completes the training pipeline by:
1. Making predictions on test set
2. Denormalizing all data (if scaler was used)
3. Reordering data by index
4. Calculating evaluation metrics (MSE, MAE, R²)
Args:
params: Training parameters used during model training
tmr: Result object from training
Returns:
TrainModelResult: Updated result with predictions, denormalized data,
and metrics (mse_val, mae_val, r2_val)
"""
# Calculate predictions BEFORE denormalization (important for polynomial models)
y_pred_array = tmr.regr.predict(tmr.x_test)
y_train_pred_array = tmr.regr.predict(tmr.x_train)
if params.use_scaler:
scaler = tmr.process_data.get_scaler()
# If using custom scaler with denormalize_* helpers
if hasattr(scaler, 'denormalize_single_input'):
for col in params.variable_columns:
tmr.x_train[col] = scaler.denormalize_single_input(tmr.x_train[col], col)
tmr.x_test[col] = scaler.denormalize_single_input(tmr.x_test[col], col)
tmr.y_train = scaler.denormalize_single_input(tmr.y_train, params.target_variable)
tmr.y_test = scaler.denormalize_single_input(tmr.y_test, params.target_variable)
y_pred_array = scaler.denormalize_predictions(y_pred_array, params.target_variable)
y_train_pred_array = scaler.denormalize_predictions(
y_train_pred_array, params.target_variable
)
else:
# Fallback for sklearn StandardScaler: only inverse-transform features
feature_cols = getattr(
tmr.process_data, 'feature_names_order', params.variable_columns
)
# Ensure columns are in the same order used during fit
x_train_features = tmr.x_train[feature_cols]
x_test_features = tmr.x_test[feature_cols]
tmr.x_train[feature_cols] = scaler.inverse_transform(x_train_features)
tmr.x_test[feature_cols] = scaler.inverse_transform(x_test_features)
# Target was not scaled with StandardScaler in preprocessing; leave y as-is
tmr.y_pred = pd.Series(y_pred_array, index=tmr.y_test.index)
tmr.y_pred.name = f'{params.target_variable}_pred'
tmr.y_train_pred = pd.Series(y_train_pred_array, index=tmr.y_train.index)
tmr.y_train_pred.name = f'{params.target_variable}_pred'
tmr.x_train = tmr.x_train.sort_index()
tmr.x_test = tmr.x_test.sort_index()
tmr.y_train = tmr.y_train.sort_index()
tmr.y_test = tmr.y_test.sort_index()
tmr.y_pred = tmr.y_pred.sort_index()
tmr.y_train_pred = tmr.y_train_pred.sort_index()
assert tmr.y_pred is not None, 'y_pred should be set at this point'
tmr.mse_val = round(
mse(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
2,
)
tmr.mae_val = round(
mae(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
2,
)
tmr.r2_val = round(r2(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)), 2)
# Extract model equation
tmr.equation = self._extract_model_equation(tmr.regr, params)
self.logger.info(
f'Model metrics calculated successfully - experiment run id: {params.experiment_run_id}'
)
return tmr
def _init_scaler_dict(self, process_data: DataPreprocessor, params: TrainModelParams) -> dict:
"""
Initialize dictionary containing scaling parameters for features and target.
This method extracts scaling parameters from the fitted scaler to enable
denormalization of predictions and debugging of the normalization process.
Args:
process_data: Fitted DataPreprocessor object with scaler
params: Training parameters including scaler configuration
Returns:
dict: Scaling parameters for each feature and target variable.
Structure depends on scaler type:
- MinMaxScaler: {'feature': {'min': float, 'max': float}, ...}
- Z_Scaler: Dictionary from scaler.create_dict()
- Empty dict: If no scaler is used
Raises:
AttributeError: If scaler doesn't have expected attributes
"""
scaler_dict = {}
if params.use_scaler:
scaler = process_data.get_scaler()
if isinstance(scaler, MinMaxScaler):
# Extract min/max for each feature
for i, col in enumerate(params.variable_columns):
scaler_dict[col] = {'min': scaler.x_min[i], 'max': scaler.x_max[i]}
# Extract min/max for target variable
scaler_dict[params.target_variable] = {
'min': scaler.y_min,
'max': scaler.y_max,
}
elif isinstance(scaler, Z_Scaler):
scaler_dict = scaler.create_dict()
return scaler_dict
def _get_static_threshold(self, params: TrainModelParams) -> int | None:
"""
Get the static threshold value based on parameters.
Args:
params: Training parameters containing static window configuration
Returns:
int | None: Static threshold value (1-1000) if rem_static_win is True, None otherwise
"""
if not params.rem_static_win:
return None
return params.static_threshold if params.static_threshold is not None else 1
def _init_data_preprocessor(self, params: TrainModelParams) -> DataPreprocessor:
"""
Initialize DataPreprocessor with training parameters.
Args:
params: Training parameters containing preprocessor configuration
Returns:
DataPreprocessor: Configured preprocessor ready for fitting
"""
# Convert removed_intervals to list of tuples if needed
removed_intervals = None
if params.removed_intervals:
removed_intervals = [
(interval[0], interval[1]) if isinstance(interval, (list, tuple)) else interval
for interval in params.removed_intervals
]
return DataPreprocessor(
target_variable=params.target_variable,
input_columns=params.variable_columns,
nan_treatment=params.nan_treatment,
lag_train=params.lag_train,
lag_transform=params.lag_val,
start_date=params.start_date,
end_date=params.end_date,
date_format=params.date_format,
removed_intervals=removed_intervals,
static_threshold=self._get_static_threshold(params),
low_lim=params.low_lim,
upp_lim=params.upp_lim,
scaler_name=params.scaler_name,
scaler_params={} if params.use_scaler else None,
ar_var=params.target_variable if params.include_ar else None,
)
def _extract_model_equation(
self, regr: LinearRegressionModel, 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
if params.degree > 1 and regr.poly_feature_names:
feature_names = regr.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': params.degree,
'interaction_only': params.interaction_only,
'original_features': params.variable_columns,
}
def _configure_datetime_index(
self, data: pd.DataFrame | None, params: TrainModelParams
) -> pd.DataFrame:
"""
Configure datetime index for the DataFrame.
Guards against None to avoid 'NoneType' object has no attribute 'index' downstream.
Prefers params.date_column when set; otherwise looks for common timestamp column names.
"""
if data is None:
raise ValueError(
'Data is None after load_data. '
'Check file format, line separator and decimal separator.'
)
if not isinstance(data, pd.DataFrame):
raise TypeError(f'Expected DataFrame, got {type(data).__name__}')
if isinstance(data.index, pd.DatetimeIndex):
self.logger.info('DataFrame already has DatetimeIndex')
return data.sort_index()
common_timestamp_columns = [
'timestamp',
'Timestamp',
'TIMESTAMP',
'date',
'Date',
'DATE',
'DATA',
'datetime',
'DateTime',
]
timestamp_columns = ([params.date_column] if params.date_column else []) + [
c for c in common_timestamp_columns if c != params.date_column
]
for col in timestamp_columns:
if col in data.columns:
try:
data[col] = pd.to_datetime(data[col])
data = data.set_index(col)
data = data.sort_index()
self.logger.info(f'Configured datetime index from column: {col}')
return data
except (ValueError, TypeError) as e:
self.logger.warning(f'Failed to convert column {col} to datetime: {e}')
continue
# If no timestamp column found, check if first column looks like a timestamp
first_col = data.columns[0]
try:
# Try to parse first column as datetime
test_values = data[first_col].head(10).dropna()
if len(test_values) > 0:
pd.to_datetime(test_values)
data[first_col] = pd.to_datetime(data[first_col])
data = data.set_index(first_col)
data = data.sort_index()
self.logger.info(f'Configured datetime index from first column: {first_col}')
return data
except (ValueError, TypeError):
pass
self.logger.warning('No timestamp column found - some features may not work correctly')
return data