SIENTIAPDE-1241: refactor train_model workflow due to I/O errors.

This commit is contained in:
Bruno Domingues
2025-10-22 15:37:56 -03:00
parent f2a1c88ff3
commit 5789a13023
31 changed files with 37878 additions and 6097 deletions

View File

@@ -7,12 +7,12 @@ with workflow.unsafe.imports_passed_through():
from sientia_do.observability.logger import Logger
from model_manager.activities.experiment_tracking import ExperimentTracking
from model_manager.activities.minio import MinIO
from model_manager.activities.mlflow import MLFlow
from model_manager.activities.training import Training
from model_manager.utils.repository.model_repository import ModelRepository
from model_manager.utils.repository.storage_repository import StorageRepository
class Activities(ExperimentTracking, MLFlow, MinIO, Training):
class Activities(ExperimentTracking, Training):
"""
Main activities orchestrator for the Model Manager system.
@@ -75,18 +75,14 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Training):
notification_handler=notification_handler,
)
MLFlow.__init__(
self,
mlflow_host=mlflow_config['host'],
mlflow_port=mlflow_config['port'],
mlflow_username=mlflow_config['username'],
mlflow_password=mlflow_config['password'],
self.model_repository = ModelRepository(
url=mlflow_config['url'],
username=mlflow_config['username'],
password=mlflow_config['password'],
logger=logger,
notification_handler=notification_handler,
)
MinIO.__init__(
self,
self.storage_repository = StorageRepository(
endpoint_url=minio_config['endpoint_url'],
access_key=minio_config['access_key'],
secret_key=minio_config['secret_key'],
@@ -97,10 +93,15 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Training):
connect_timeout=minio_config['connect_timeout'],
read_timeout=minio_config['read_timeout'],
logger=logger,
notification_handler=notification_handler,
)
Training.__init__(self, logger=logger, notification_handler=notification_handler)
Training.__init__(
self,
model_repository=self.model_repository,
storage_repository=self.storage_repository,
logger=logger,
notification_handler=notification_handler,
)
def __del__(self):
"""

View File

@@ -9,7 +9,9 @@ methods for experiment management.
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import asyncio
import traceback
from collections.abc import Mapping
from datetime import UTC, datetime
from enum import Enum
from typing import Any
@@ -18,6 +20,7 @@ with workflow.unsafe.imports_passed_through():
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.temporal.activities.postgres import Postgres
from sqlalchemy import text
class UpdateType(str, Enum):
@@ -85,6 +88,7 @@ class ExperimentTracking(Postgres):
logger=logger,
notification_handler=notification_handler,
)
self.logger = logger
self.notification_handler = notification_handler
@@ -105,6 +109,14 @@ class ExperimentTracking(Postgres):
# Silently ignore errors during garbage collection
pass
async def _execute_update(self, query: str, params: Mapping[str, Any]) -> dict[str, Any]:
def _run() -> dict[str, Any]:
with self.engine.begin() as connection:
result = connection.execute(text(query), params)
return {'rowcount': result.rowcount}
return await asyncio.to_thread(_run)
@activity.defn(name='update_experiment_run')
async def update_experiment_run(self, input_data: dict[str, Any]) -> None:
"""
@@ -128,32 +140,6 @@ class ExperimentTracking(Postgres):
Raises:
ValueError: If required parameters are missing for the update type
RuntimeError: If update operation fails
Example:
# Update status only
await update_experiment_run({
'metadata': {},
'experiment_run_id': 123,
'update_type': 'status',
'status': 'TRAINING_SUCCESS'
})
# Update with error
await update_experiment_run({
'metadata': {},
'experiment_run_id': 123,
'update_type': 'status_with_error',
'status': 'TRAINING_ERROR',
'error_message': 'Model training failed: insufficient data'
})
# Update with model saved
await update_experiment_run({
'metadata': {},
'experiment_run_id': 123,
'update_type': 'model_saved',
'run_name': 'experiment-model-5'
})
"""
metadata = input_data.get('metadata', {})
experiment_run_id = input_data['experiment_run_id']
@@ -164,71 +150,86 @@ class ExperimentTracking(Postgres):
try:
self.info(
f'Updating experiment run {experiment_run_id} with type: {update_type}', metadata
f'Updating experiment run {experiment_run_id} with status: {status}', metadata
)
# Validate parameters based on update type
query_params: tuple[Any, ...]
query_params: dict[str, Any]
if update_type == UpdateType.STATUS:
if not status:
if not isinstance(status, str) or not status:
raise ValueError('status is required for STATUS update type')
sql_query = """
UPDATE experiment_run
SET status = %s, updated_at = %s
WHERE id = %s
SET status = :status, updated_at = :updated_at
WHERE id = :experiment_run_id
"""
query_params = (status, datetime.now(UTC), experiment_run_id)
query_params = {
'status': status,
'updated_at': datetime.now(UTC),
'experiment_run_id': experiment_run_id,
}
elif update_type == UpdateType.STATUS_WITH_ERROR:
if not status or not error_message:
raise ValueError(
'status and error_message are required for STATUS_WITH_ERROR update type'
)
if not isinstance(status, str) or not status:
raise ValueError('status is required for STATUS_WITH_ERROR update type')
if not isinstance(error_message, str) or not error_message:
raise ValueError('error_message is required for STATUS_WITH_ERROR update type')
# Truncate the error_message to 1024 characters if necessary
if len(error_message) > 1024:
error_message = error_message[:1024]
sql_query = """
UPDATE experiment_run
SET status = %s, error_message = %s, updated_at = %s
WHERE id = %s
SET status = :status, error_message = :error_message, updated_at = :updated_at
WHERE id = :experiment_run_id
"""
query_params = (
status,
error_message,
datetime.now(UTC),
experiment_run_id,
)
query_params = {
'status': status,
'error_message': error_message,
'updated_at': datetime.now(UTC),
'experiment_run_id': experiment_run_id,
}
elif update_type == UpdateType.MODEL_SAVED:
if not run_name:
if not isinstance(run_name, str) or not run_name:
raise ValueError('run_name is required for MODEL_SAVED update type')
if not isinstance(status, str) or not status:
raise ValueError('status is required for MODEL_SAVED update type')
sql_query = """
UPDATE experiment_run
SET run_name = %s, status = %s, updated_at = %s
WHERE id = %s
SET run_name = :run_name, status = :status, updated_at = :updated_at
WHERE id = :experiment_run_id
"""
query_params = (run_name, status, datetime.now(UTC), experiment_run_id)
query_params = {
'run_name': run_name,
'status': status,
'updated_at': datetime.now(UTC),
'experiment_run_id': experiment_run_id,
}
else:
raise ValueError(f'Invalid update_type: {update_type}')
# Execute update query
result = await self.execute_query(sql_query, query_params)
result = await self._execute_update(sql_query, query_params)
if result.get('rowcount', 0) == 0:
error_msg = f'No rows updated for experiment run {experiment_run_id}'
error_msg = (
f'No rows updated for experiment run {experiment_run_id} with status {status}'
)
raise ValueError(error_msg)
self.info(
f'Successfully updated experiment run {experiment_run_id} with type {update_type}',
f'Successfully updated experiment run {experiment_run_id} with status {status}',
metadata,
)
except Exception as e: # noqa: BLE001
error_msg = f'Error updating experiment run - ID: {experiment_run_id}, Type: {update_type}, Error: {str(e)}'
error_msg = f'Error updating experiment run - ID: {experiment_run_id}, Status: {status}, Error: {str(e)}'
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='UPDATE_EXPERIMENT_RUN_ERROR',
@@ -237,5 +238,6 @@ class ExperimentTracking(Postgres):
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.error(trace, metadata=metadata)
raise RuntimeError(error_msg) from e

View File

@@ -1,229 +0,0 @@
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from io import BytesIO
from typing import Any
import boto3 # type: ignore[import-untyped]
from botocore.config import Config # type: ignore[import-untyped]
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.temporal.activities.base import BaseActivity
class MinIO(BaseActivity):
"""
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,
notification_handler: NotificationHandler,
):
"""
Initialize MinIO activities with server configuration.
This constructor creates a persistent boto3 S3 client that will be
reused across all activity calls. The client is thread-safe and
includes automatic 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 mode - standard, legacy, or adaptive (e.g., adaptive)
connect_timeout: Connection timeout in seconds (e.g., 10)
read_timeout: Read timeout in seconds (e.g., 60)
logger: Logger instance for observability and debugging
notification_handler: Notification handler for alerts and monitoring
Raises:
ConnectionError: If boto3 client initialization fails
"""
BaseActivity.__init__(self, logger, notification_handler, set_error_counter=True)
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
# Configure boto3 with retry strategy
# This handles transient network errors and connection issues automatically
boto_config = Config(
region_name=region,
retries={
'max_attempts': max_retry_attempts,
'mode': retry_mode,
},
connect_timeout=connect_timeout,
read_timeout=read_timeout,
)
try:
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.info(f'MinIO client initialized successfully: {endpoint_url}')
except Exception as e:
error_msg = f'Failed to initialize MinIO client: {str(e)}'
self.error(error_msg)
raise ConnectionError(error_msg) from e
@activity.defn(name='fetch_file_from_minio')
async def fetch_file_from_minio(self, input_data: dict[str, Any]) -> BytesIO:
"""
Fetch a file from MinIO and return its content as a BytesIO object.
This activity downloads a file from a MinIO bucket and returns the
content as a BytesIO object, which is a file-like object that can be
used directly with many Python libraries (pandas, PIL, etc.).
The operation includes:
1. Input validation
2. File download from MinIO
3. Content reading and wrapping in BytesIO
4. Comprehensive error handling and logging
Args:
input_data: Configuration for file fetch operation
Required keys:
- metadata (dict): Workflow execution metadata
- bucket_name (str): MinIO bucket name
- file_name (str): File path/key in the bucket
Returns:
BytesIO: File content as a file-like object
Raises:
OSError: If file fetch fails due to network, permission, or other errors
"""
metadata = input_data.get('metadata', {})
bucket_name = input_data['bucket_name']
file_name = input_data['file_name']
self.info(f'Fetching file from MinIO: {bucket_name}/{file_name}', metadata)
try:
# Download file from MinIO
response = self.minio_client.get_object(Bucket=bucket_name, Key=file_name)
# Read file content
with response['Body'] as body:
file_content = body.read()
file_size = len(file_content)
self.info(
f'File fetched successfully: {bucket_name}/{file_name} ({file_size} bytes)',
metadata,
)
return BytesIO(file_content)
except Exception as e: # noqa: BLE001
error_msg = f'Error fetching file from MinIO - Bucket: {bucket_name}, File: {file_name}, Error: {str(e)}'
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='FETCH_FILE_FROM_MINIO_ERROR',
message=error_msg,
block='fetch_file_from_minio',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.error(trace, metadata=metadata)
raise OSError(error_msg) from e
@activity.defn(name='delete_file_from_minio')
async def delete_file_from_minio(self, input_data: dict[str, Any]) -> None:
"""
Delete a file from MinIO storage.
This activity removes a file from a MinIO bucket. The operation is
idempotent - deleting a non-existent file is considered successful.
The operation includes:
1. Input validation
2. File deletion from MinIO
3. Comprehensive error handling and logging
Args:
input_data: Configuration for file deletion operation
Required keys:
- metadata (dict): Workflow execution metadata
- bucket_name (str): MinIO bucket name
- file_name (str): File path/key to delete
Returns:
None
Raises:
OSError: If file deletion fails due to permission or other errors
"""
metadata = input_data.get('metadata', {})
bucket_name = input_data['bucket_name']
file_name = input_data['file_name']
self.info(f'Deleting file from MinIO: {bucket_name}/{file_name}', metadata)
try:
# Delete file from MinIO
# Note: delete_object is idempotent - no error if file doesn't exist
self.minio_client.delete_object(Bucket=bucket_name, Key=file_name)
self.info(f'File deleted successfully: {bucket_name}/{file_name}', metadata)
except Exception as e: # noqa: BLE001
error_msg = f'Error deleting file from MinIO - Bucket: {bucket_name}, File: {file_name}, Error: {str(e)}'
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='DELETE_FILE_FROM_MINIO_ERROR',
message=error_msg,
block='delete_file_from_minio',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.error(trace, metadata=metadata)
raise OSError(error_msg) from e

View File

@@ -1,214 +0,0 @@
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from typing import Any
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.temporal.activities.base import BaseActivity
from model_manager.utils.models.train_model_result import TrainModelResult
from model_manager.utils.repository.model_repository import MLFlowRepository
class MLFlow(BaseActivity):
"""
MLFlow integration activities for model training operations.
This class provides activities for saving trained models and managing
artifacts in MLFlow. It handles model persistence, artifact generation,
and cleanup operations with comprehensive error handling.
The class implements robust error handling and logging for all
MLFlow operations, ensuring reliable model management in production environments.
Attributes:
mlflow_host (str): MLFlow server hostname
mlflow_port (int): MLFlow server port
mlflow_username (str): MLFlow authentication username
mlflow_password (str): MLFlow authentication password
model_monitoring_repository (MLFlowRepository): Repository for MLFlow operations
"""
def __init__(
self,
mlflow_host: str,
mlflow_port: int,
mlflow_username: str,
mlflow_password: str,
logger: Logger,
notification_handler: NotificationHandler,
):
"""
Initialize MLFlow activities with server configuration.
Args:
mlflow_host: MLFlow server hostname or IP address
mlflow_port: MLFlow server port number
mlflow_username: Username for MLFlow authentication
mlflow_password: Password for MLFlow authentication
logger: Logger instance for observability and debugging
notification_handler: Notification handler for alerts and monitoring
Raises:
Exception: If MLFlowRepository initialization fails
"""
BaseActivity.__init__(self, logger, notification_handler, set_error_counter=True)
self.mlflow_host = mlflow_host
self.mlflow_port = mlflow_port
self.mlflow_username = mlflow_username
self.mlflow_password = mlflow_password
self.model_monitoring_repository = MLFlowRepository(
f'{mlflow_host}:{mlflow_port}', mlflow_username, mlflow_password, logger
)
@activity.defn(name='save_model')
async def save_model(self, input_data: dict[str, Any]) -> 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)
Example:
result = await save_model({
'metadata': {'workflow_id': 'save-123', 'experiment_run_id': 456},
'train_result': TrainModelResult(...)
})
# Returns: TrainModelResult with run_name and artifacts
"""
metadata = input_data.get('metadata', {})
train_result = input_data['train_result']
try:
experiment_name = train_result.params.experiment_name
self.info(
f'Starting model save for experiment: {experiment_name}',
metadata,
)
# Step 1: Generate next run name
self.info('Generating run name', metadata)
train_result.run_name = self.model_monitoring_repository.get_next_run_name(
experiment_name
)
self.info(f'Generated run name: {train_result.run_name}', metadata)
# Step 2: Generate artifacts (reports, CSV files)
self.info('Generating artifacts', metadata)
train_result = self.model_monitoring_repository.generate_artifacts(train_result)
self.info('Artifacts generated successfully', metadata)
# Step 3: Save run to MLflow
self.info('Saving run to MLflow', metadata)
self.model_monitoring_repository.save_run(train_result)
self.info(
f'Model saved successfully - Run: {train_result.run_name}, '
f'Experiment: {experiment_name}',
metadata,
)
return train_result
except Exception as e: # noqa: BLE001
error_msg = f'Error saving model - Experiment: {train_result.params.experiment_name if train_result and train_result.params else "unknown"}, Error: {str(e)}'
trace = traceback.format_exc()
# Send notification (MongoDB)
self.send_notification(
metadata=metadata,
notification_id='SAVE_MODEL_ERROR',
message=error_msg,
block='save_model',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
# Log error with metadata
self.error(trace, metadata=metadata)
# Re-raise exception to stop workflow
raise
@activity.defn(name='cleanup_run_directory')
async def cleanup_run_directory(self, input_data: dict[str, Any]) -> 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:
input_data: Configuration for cleanup operation
Required keys:
- metadata (dict): Workflow execution metadata
- run_dir (str): Path to the run directory to delete
Raises:
Exception: If cleanup fails for reasons other than directory not existing
Example:
await cleanup_run_directory({
'metadata': {'workflow_id': 'cleanup-123'},
'run_dir': '/path/to/run_dir'
})
"""
import os
import shutil
metadata = input_data.get('metadata', {})
run_dir = input_data.get('run_dir')
try:
if not run_dir:
self.info('No run directory specified, skipping cleanup', metadata)
return
self.info(f'Cleaning up run directory: {run_dir}', metadata)
# Idempotent cleanup: check if directory exists before deleting
if os.path.exists(run_dir):
shutil.rmtree(run_dir)
self.info(f'Run directory deleted successfully: {run_dir}', metadata)
else:
self.info(f'Run directory already deleted: {run_dir}', metadata)
except Exception as e:
error_msg = f'Error cleaning up run directory {run_dir}: {str(e)}'
trace = traceback.format_exc()
# Send notification
self.send_notification(
metadata=metadata,
notification_id='CLEANUP_RUN_DIRECTORY_ERROR',
message=error_msg,
block='cleanup_run_directory',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
# Log error
self.error(trace, metadata=metadata)
# Re-raise exception
raise

View File

@@ -10,7 +10,6 @@ from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from io import BytesIO
from typing import Any
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
@@ -18,8 +17,10 @@ with workflow.unsafe.imports_passed_through():
from sientia_do.observability.logger import Logger
from sientia_do.temporal.activities.base import BaseActivity
from model_manager.utils.exceptions import ModelTrainingError
from model_manager.utils.models.train_model_params import TrainModelParams
from model_manager.utils.models.train_model_result import TrainModelResult
from model_manager.utils.repository.model_repository import ModelRepository
from model_manager.utils.repository.storage_repository import StorageRepository
from model_manager.utils.repository.training_repository import TrainingRepository
@@ -39,6 +40,8 @@ class Training(BaseActivity):
def __init__(
self,
model_repository: ModelRepository,
storage_repository: StorageRepository,
logger: Logger,
notification_handler: NotificationHandler,
):
@@ -49,8 +52,10 @@ class Training(BaseActivity):
logger: Logger instance for observability
notification_handler: Handler for sending notifications
"""
BaseActivity.__init__(self, logger, notification_handler, set_error_counter=True)
super().__init__(logger, notification_handler, set_error_counter=True)
self.training_repository = TrainingRepository(logger)
self.model_repository = model_repository
self.storage_repository = storage_repository
@activity.defn(name='validate_train_params')
async def validate_train_params(self, input_data: dict[str, Any]) -> TrainModelParams:
@@ -71,27 +76,12 @@ class Training(BaseActivity):
Raises:
ValueError, TypeError, KeyError: If validation fails (after sending notification)
Example:
result = await validate_train_params({
'metadata': {'workflow_id': 'train-123'},
'experiment_run_id': 456,
'target_variable': 'price',
'variable_columns': ['feature1', 'feature2'],
'train_size': 80,
# ... other required fields at same level
})
# Returns: TrainModelParams(...)
"""
metadata = input_data.get('metadata', {})
try:
self.info('Validating training parameters', metadata)
# Step 1: Convert input_data to TrainModelParams (validates types and required fields)
train_params = TrainModelParams.from_dict(input_data)
# Step 2: Validate business rules (ranges, consistency, etc.)
train_params.validate_business_rules()
self.info(
@@ -102,12 +92,10 @@ class Training(BaseActivity):
)
return train_params
except (ValueError, TypeError, KeyError) as e:
error_msg = f'Error validating training parameters: {str(e)}'
trace = traceback.format_exc()
# Send notification (MongoDB)
self.send_notification(
metadata=metadata,
notification_id='VALIDATE_TRAIN_PARAMS_ERROR',
@@ -117,14 +105,11 @@ class Training(BaseActivity):
attachment_content=trace,
)
# Log error with metadata
self.error(trace, metadata=metadata)
# Re-raise exception to stop workflow
raise
@activity.defn(name='train_model')
async def train_model(self, input_data: dict[str, Any]) -> TrainModelResult:
async def train_model(self, input_data: dict[str, Any]) -> dict[str, str | None]:
"""
Train a machine learning model.
@@ -156,51 +141,42 @@ class Training(BaseActivity):
# Returns: TrainModelResult(...)
"""
metadata = input_data.get('metadata', {})
uploaded_file = input_data['uploaded_file']
train_params = input_data['train_params']
if isinstance(train_params, dict):
train_params = TrainModelParams.from_dict(train_params)
# type: ignore[assignment]
model_trained = False
model_saved = False
try:
# Validate uploaded_file is BytesIO
if not isinstance(uploaded_file, BytesIO):
raise ValueError(f'uploaded_file must be BytesIO, got {type(uploaded_file)}')
with self.storage_repository.fetch_file(
train_params.bucket_name, train_params.file_name
) as uploaded_file:
train_result = self.training_repository.train(uploaded_file, train_params)
# Validate train_params is TrainModelParams
if not isinstance(train_params, TrainModelParams):
raise ValueError(f'train_params must be TrainModelParams, got {type(train_params)}')
train_result = self.training_repository.after_train_calculation(
train_params, train_result
)
self.info(
f'Starting model training for target: {train_params.target_variable}',
metadata,
)
# Step 1: Train the model
self.info('Training model with TrainingRepository', metadata)
train_result = self.training_repository.train(uploaded_file, train_params)
# Step 2: Perform post-training calculations
self.info('Performing post-training calculations', metadata)
final_result = self.training_repository.after_train_calculation(
train_params, train_result
)
self.info(
f'Model training completed successfully - '
f'MSE: {final_result.mse_val}, MAE: {final_result.mae_val}, R²: {final_result.r2_val}',
metadata,
)
return final_result
model_trained = True
train_result = self.model_repository.save_model(train_result)
model_saved = True
return {
'run_name': train_result.run_name,
'run_dir': train_result.run_dir,
}
except Exception as e: # noqa: BLE001
target = (
train_params.target_variable
if hasattr(train_params, 'target_variable')
else 'unknown'
error_msg = (
'Error training model - '
f'model_trained={model_trained}, model_saved={model_saved}, '
f'error: {str(e)}'
)
error_msg = f'Error training model - Target: {target}, Error: {str(e)}'
trace = traceback.format_exc()
# Send notification (MongoDB)
self.send_notification(
metadata=metadata,
notification_id='TRAIN_MODEL_ERROR',
@@ -210,8 +186,39 @@ class Training(BaseActivity):
attachment_content=trace,
)
# Log error with metadata
self.error(trace, metadata=metadata)
# Re-raise exception to stop workflow
raise ModelTrainingError(
model_trained=model_trained,
model_saved=model_saved,
) from e
@activity.defn(name='cleanup_resources')
async def cleanup_resources(self, input_data: dict[str, Any]) -> None:
metadata = input_data.get('metadata', {})
run_dir = input_data.get('run_dir', '')
bucket_name = input_data.get('bucket_name', '')
file_name = input_data.get('file_name', '')
try:
self.model_repository.cleanup_run_directory(run_dir)
self.storage_repository.delete_file(bucket_name, file_name)
except Exception as e: # noqa: BLE001
error_msg = (
f'Error cleaning up resources - Run directory: {run_dir}, '
f'File: {bucket_name}/{file_name}, Error: {str(e)}'
)
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='CLEANUP_RESOURCES_ERROR',
message=error_msg,
block='cleanup_resources',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.error(trace, metadata=metadata)
raise

View File

@@ -31,7 +31,6 @@ class ModelServing:
tracking_uri: str,
username: str | None = None,
password: str | None = None,
logger: Any | None = None,
):
"""
Initialize ModelServing client.
@@ -54,9 +53,6 @@ class ModelServing:
if password is not None:
os.environ['MLFLOW_TRACKING_PASSWORD'] = password
# Note: logger parameter is accepted but not used
# Consider removing if not needed, or implement logging
# Function to list runs for a given experiment
def search_runs_by_name(
self, experiment_names: list[str], order_by: None | list[str] = None

View File

@@ -51,8 +51,7 @@ def build_mlflow_config() -> dict[str, Any]:
dict: MLFlow configuration dictionary with all required parameters
"""
return {
'host': getenv('MLFLOW_HOST', 'http://localhost'),
'port': int(getenv('MLFLOW_PORT', '5080')),
'url': getenv('MLFLOW_URL', 'http://localhost:5080'),
'username': getenv('MLFLOW_USERNAME', 'aignosi'),
'password': getenv('MLFLOW_PASSWORD', 'aignosi'),
}

View File

@@ -0,0 +1,12 @@
class ModelTrainingError(Exception):
def __init__(self, model_trained: bool, model_saved: bool, message: str | None = None):
self.model_trained = model_trained
self.model_saved = model_saved
if message is None:
message = (
'Model training workflow failed '
f'(model_trained={model_trained}, model_saved={model_saved})'
)
super().__init__(message)

View File

@@ -184,22 +184,22 @@ class TrainModelParams:
>>> params = TrainModelParams.from_dict(data)
>>> params.validate_business_rules() # Raises ValueError if invalid
"""
# Validate train_size range (1-99%)
if not 1 <= self.train_size <= 99:
raise ValueError(f'train_size must be between 1 and 99, got {self.train_size}')
# Validate train_size range (10-100%)
if not 10 <= self.train_size <= 100:
raise ValueError(f'train_size must be between 10 and 100, got {self.train_size}')
# Validate variable_columns is not empty
if not self.variable_columns:
raise ValueError('variable_columns cannot be empty')
# Validate positive integers
if self.lag_train <= 0:
if self.lag_train < 0:
raise ValueError(f'lag_train must be positive, got {self.lag_train}')
if self.lag_val <= 0:
if self.lag_val < 0:
raise ValueError(f'lag_val must be positive, got {self.lag_val}')
if self.window <= 0:
if self.window < 0:
raise ValueError(f'window must be positive, got {self.window}')
# Validate low_lim and upp_lim consistency
@@ -218,13 +218,6 @@ class TrainModelParams:
f'Got low_lim={self.low_lim[var]}, upp_lim={self.upp_lim[var]}'
)
# Validate target_variable is in variable_columns
if self.target_variable not in self.variable_columns:
raise ValueError(
f'target_variable "{self.target_variable}" must be in variable_columns: '
f'{self.variable_columns}'
)
# Validate bucket_name and file_name are not empty
if not self.bucket_name.strip():
raise ValueError('bucket_name cannot be empty or whitespace')

View File

@@ -9,6 +9,7 @@ and logging model runs to MLFlow.
"""
import os
import shutil
from datetime import datetime
from os import makedirs, path
@@ -22,14 +23,81 @@ from model_manager.sientia.reports import Reports # type: ignore[import-untyped
from model_manager.utils.models.train_model_result import TrainModelResult
class MLFlowRepository:
def __init__(self, host, username, password, logger: Logger):
self.model_serving = ModelServing(
tracking_uri=host, username=username, password=password, logger=logger
)
class ModelRepository:
def __init__(self, url, username, password, logger: Logger):
self.model_serving = ModelServing(tracking_uri=url, username=username, password=password)
self.logger = logger
def get_next_run_name(self, experiment_name: str) -> str:
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
self.logger.info(f'Starting model save for experiment: {experiment_name}')
# Step 1: Generate next run name
self.logger.info('Generating run name')
train_result.run_name = self._get_next_run_name(experiment_name)
self.logger.info(f'Generated run name: {train_result.run_name}')
# Step 2: Generate artifacts (reports, CSV files)
self.logger.info('Generating artifacts')
train_result = self._generate_artifacts(train_result)
self.logger.info('Artifacts generated successfully')
# Step 3: Save run to MLflow
self.logger.info('Saving run to MLflow')
self._save_run(train_result)
self.logger.info(
f'Model saved successfully - Run: {train_result.run_name}, '
f'Experiment: {experiment_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
self.logger.info(f'Cleaning up run directory: {run_dir}')
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.
@@ -46,7 +114,7 @@ class MLFlowRepository:
next_run_number = len(runs) + 1
return f'{experiment_name}-{next_run_number}'
def generate_artifacts(self, data: TrainModelResult) -> TrainModelResult:
def _generate_artifacts(self, data: TrainModelResult) -> TrainModelResult:
"""
Generates and organizes artifacts related to the training process, such as reports and data files.
@@ -87,7 +155,7 @@ class MLFlowRepository:
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):
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.
@@ -122,60 +190,48 @@ class MLFlowRepository:
self.logger.error(error_msg)
raise ValueError(error_msg)
try:
# Prepare parameters
train_test_split = f'{data.params.train_size}-{100 - data.params.train_size}'
interval_strs = [
(str(interval[0]), str(interval[1]))
for interval in (data.params.removed_intervals or [])
]
# Prepare parameters
train_test_split = f'{data.params.train_size}-{100 - data.params.train_size}'
# Set experiment and create run
self.model_serving.set_experiment(data.params.experiment_name)
self.logger.info(
f"Logging run '{data.run_name}' to experiment '{data.params.experiment_name}'"
)
interval_strs = [
(str(interval[0]), str(interval[1]))
for interval in (data.params.removed_intervals or [])
]
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_type', 'Linear Regression')
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('lag_train', data.params.lag_train)
self.model_serving.log_param('lag_val', data.params.lag_val)
self.model_serving.log_param('ma', data.params.window)
self.model_serving.log_param('low_lim', data.params.low_lim)
self.model_serving.log_param('upp_lim', data.params.upp_lim)
self.model_serving.log_param('normalized', data.scaler_dict)
self.model_serving.log_param('ar', data.params.include_ar)
self.model_serving.log_param('Train_test_split', train_test_split)
self.model_serving.log_param('Removed_intervals', interval_strs)
self.model_serving.log_param('Retrain', False)
# Set experiment and create run
self.model_serving.set_experiment(data.params.experiment_name)
# 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)
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_type', 'Linear Regression')
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('lag_train', data.params.lag_train)
self.model_serving.log_param('lag_val', data.params.lag_val)
self.model_serving.log_param('ma', data.params.window)
self.model_serving.log_param('low_lim', data.params.low_lim)
self.model_serving.log_param('upp_lim', data.params.upp_lim)
self.model_serving.log_param('normalized', data.scaler_dict)
self.model_serving.log_param('ar', data.params.include_ar)
self.model_serving.log_param('Train_test_split', train_test_split)
self.model_serving.log_param('Removed_intervals', interval_strs)
self.model_serving.log_param('Retrain', False)
# Log models
self.model_serving.log_model(data.process_data, 'data_model')
self.model_serving.log_model(data.regr, 'prediction_model')
# 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 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 models
self.model_serving.log_model(data.process_data, 'data_model')
self.model_serving.log_model(data.regr, 'prediction_model')
self.logger.info(
f"Successfully logged run '{data.run_name}' with metrics: MSE={data.mse_val:.4f}, R2={data.r2_val:.4f}, MAE={data.mae_val:.4f}"
)
except Exception as e:
error_msg = f"Failed to save run '{data.run_name}' to MLflow: {str(e)}"
self.logger.error(error_msg)
raise RuntimeError(error_msg) from e
# 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)
def _init_artifacts_data(self, data: TrainModelResult) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
@@ -258,7 +314,6 @@ class MLFlowRepository:
try:
makedirs(run_dir, exist_ok=True)
self.logger.info(f'Created run directory: {run_dir}')
return run_dir
except PermissionError as e:
error_msg = f'Permission denied when creating directory: {run_dir}'
@@ -298,9 +353,6 @@ class MLFlowRepository:
# Copy header file to run directory
header_dest = path.join(run_dir, 'header.html')
shutil.copy(header_file_path, header_dest)
self.logger.info(f'Run directory setup completed successfully in: {run_dir}')
except FileNotFoundError as e:
error_msg = f'Header file not found: {header_file_path}'
self.logger.error(error_msg)
@@ -360,20 +412,16 @@ class MLFlowRepository:
# Save HTML report
data.report_path = path.join(data.run_dir, 'report.html')
report.save_all_sections_html(data.report_path)
self.logger.info(f'Generated HTML report: {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)
self.logger.info(f'Saved training data: {data.train_data_path}')
# 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)
self.logger.info(f'Saved test data: {data.test_data_path}')
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)

View File

@@ -0,0 +1,129 @@
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 successfully: {endpoint_url}')
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.).
"""
self.logger.info(f'Fetching file from MinIO: {bucket_name}/{file_name}')
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.logger.info(f'Deleting file from MinIO: {bucket_name}/{file_name}')
self.minio_client.delete_object(Bucket=bucket_name, Key=file_name)
self.logger.info(f'File deleted successfully: {bucket_name}/{file_name}')

View File

@@ -66,19 +66,17 @@ class TrainingRepository:
ValueError: If transformed data is empty
Exception: If data loading, preprocessing, or training fails
"""
# Load data from BytesIO
self.logger.info('Loading data from BytesIO file')
data = load_data(uploaded_file, params.line_separator, params.decimal_separator)
# Initialize and fit data preprocessor
process_data = self.init_data_preprocessor(params)
self.logger.info('Initializing and fitting data preprocessor')
process_data = self._init_data_preprocessor(params)
process_data.fit(data)
data_view = process_data.transform(data)
# Validate transformed data
if len(data_view) <= 0:
raise ValueError('Data view is empty after transformation')
# Split data into train/test sets
self.logger.info('Splitting data into train/test sets')
x_train, x_test, y_train, y_test = split_train_test(
data_view[params.variable_columns],
data_view[params.target_variable],
@@ -87,18 +85,18 @@ class TrainingRepository:
random_state=42,
)
# Prepare training data
self.logger.info('Preparing training data')
data_train = pd.concat([x_train, y_train], axis=1)
scaler_dict = self.init_scaler_dict(process_data, params)
scaler_dict = self._init_scaler_dict(process_data, params)
# Create and train linear regression model
self.logger.info('Training linear regression model')
regr = LinearRegressionModel(
target_variable=params.target_variable,
variable_columns=params.variable_columns,
)
regr.fit(data_train)
# Return training result
return TrainModelResult(
params=params,
process_data=process_data,
@@ -110,7 +108,70 @@ class TrainingRepository:
scaler_dict=scaler_dict,
)
def init_scaler_dict(self, process_data: DataPreprocessor, params: TrainModelParams) -> 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)
"""
self.logger.info('Making predictions on test set')
y_pred_array = tmr.regr.predict(tmr.x_test)
if params.use_scaler:
scaler = tmr.process_data.get_scaler()
self.logger.info('Denormalizing features')
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)
self.logger.info('Denormalizing target variable')
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)
self.logger.info('Adding index to predictions')
tmr.y_pred = pd.Series(y_pred_array, index=tmr.y_test.index)
tmr.y_pred.name = f'{params.target_variable}_pred'
self.logger.info('Reordering all data by index')
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()
self.logger.info('Calculating evaluation metrics')
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 _init_scaler_dict(self, process_data: DataPreprocessor, params: TrainModelParams) -> dict:
"""
Initialize dictionary containing scaling parameters for features and target.
@@ -152,69 +213,7 @@ class TrainingRepository:
return 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)
"""
# Make predictions on test set
y_pred_array = tmr.regr.predict(tmr.x_test)
# Denormalize data if scaler was used
if params.use_scaler:
scaler = tmr.process_data.get_scaler()
# Denormalize features
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)
# Denormalize target variable
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)
# Add index to predictions
tmr.y_pred = pd.Series(y_pred_array, index=tmr.y_test.index)
tmr.y_pred.name = f'{params.target_variable}_pred'
# Reorder all data by index
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()
# Calculate evaluation metrics
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 init_data_preprocessor(self, params: TrainModelParams) -> DataPreprocessor:
def _init_data_preprocessor(self, params: TrainModelParams) -> DataPreprocessor:
"""
Initialize DataPreprocessor with training parameters.

View File

@@ -128,18 +128,10 @@ async def main():
task_queue='train_model-queue',
workflows=[TrainModel],
activities=[
# Training & Validation
activities.update_experiment_run,
activities.validate_train_params,
activities.train_model,
# MLFlow
activities.save_model,
# MinIO
activities.fetch_file_from_minio,
activities.delete_file_from_minio,
# Filesystem
activities.cleanup_run_directory,
# Database
activities.update_experiment_run,
activities.cleanup_resources,
],
max_concurrent_workflow_tasks=50,
max_concurrent_activities=50,

View File

@@ -20,18 +20,15 @@ with workflow.unsafe.imports_passed_through():
from model_manager.activities.activities import Activities
from model_manager.activities.experiment_tracking import UpdateType
from model_manager.utils.exceptions import ModelTrainingError
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
# Activity Timeouts (in seconds) - Configurable via environment variables
# Defaults are designed to handle large files (up to 200MB)
TIMEOUT_VALIDATE_PARAMS = int(os.getenv('TIMEOUT_VALIDATE_PARAMS', '30'))
TIMEOUT_DOWNLOAD_FILE = int(os.getenv('TIMEOUT_DOWNLOAD_FILE', '600'))
TIMEOUT_TRAIN_MODEL = int(os.getenv('TIMEOUT_TRAIN_MODEL', '1800'))
TIMEOUT_SAVE_MODEL = int(os.getenv('TIMEOUT_SAVE_MODEL', '300'))
TIMEOUT_CLEANUP_DIRECTORY = int(os.getenv('TIMEOUT_CLEANUP_DIRECTORY', '60'))
TIMEOUT_DELETE_FILE = int(os.getenv('TIMEOUT_DELETE_FILE', '60'))
TIMEOUT_TRAIN_MODEL = int(os.getenv('TIMEOUT_TRAIN_MODEL', '2700'))
TIMEOUT_DELETE_FILE = int(os.getenv('TIMEOUT_DELETE_FILE', '120'))
TIMEOUT_UPDATE_DATABASE = int(os.getenv('TIMEOUT_UPDATE_DATABASE', '30'))
# Retry Policies - Granular strategies for different operation types
@@ -48,14 +45,6 @@ with workflow.unsafe.imports_passed_through():
maximum_attempts=1,
)
# Moderate retry with backoff for MLFlow operations
mlflow_retry_policy = RetryPolicy(
initial_interval=timedelta(seconds=5),
maximum_interval=timedelta(seconds=30),
backoff_coefficient=2.0,
maximum_attempts=3,
)
# Database retry with exponential backoff
database_retry_policy = RetryPolicy(
initial_interval=timedelta(seconds=2),
@@ -64,14 +53,6 @@ with workflow.unsafe.imports_passed_through():
maximum_attempts=5,
)
# Filesystem retry for cleanup operations
filesystem_retry_policy = RetryPolicy(
initial_interval=timedelta(seconds=2),
maximum_interval=timedelta(seconds=10),
backoff_coefficient=1.5,
maximum_attempts=3,
)
@workflow.defn(name='train_model')
class TrainModel:
@@ -113,8 +94,6 @@ class TrainModel:
Raises:
ValueError: If experiment_run_id is missing or invalid
"""
# CRITICAL: Validate experiment_run_id first
# Without it, we cannot update database status, so fail immediately
experiment_run_id = self._validate_experiment_run_id(input_data)
metadata = {
@@ -124,33 +103,21 @@ class TrainModel:
}
}
# Step 1: Validate and convert training parameters
train_params = await self._validate_training_parameters(
input_data, experiment_run_id, metadata
)
# Step 2 & 3: Download from MinIO and Train model
# The _download_and_train_model method handles both steps:
# - Downloads file from MinIO (returns BytesIO)
# - Trains model with the downloaded file
# Any error (download OR training) = TRAINING_ERROR
train_result = await self._download_and_train_model(
train_result = await self._train_model(
train_params=train_params,
experiment_run_id=experiment_run_id,
metadata=metadata,
)
# Step 4: Save model to MLFlow
saved_result = await self._save_model_to_mlflow(
train_result=train_result,
experiment_run_id=experiment_run_id,
metadata=metadata,
)
# Step 5: Cleanup resources and delete file from MinIO
await self._cleanup_resources(
saved_result=saved_result,
experiment_run_id=experiment_run_id,
run_dir=(train_result.get('run_dir') or ''),
bucket_name=train_params.bucket_name,
file_name=train_params.file_name,
metadata=metadata,
)
@@ -174,16 +141,12 @@ class TrainModel:
experiment_run_id = input_data.get('experiment_run_id')
if experiment_run_id is None:
error_msg = 'experiment_run_id is required but was not provided'
workflow.logger.error(error_msg)
raise ValueError(error_msg)
raise ValueError('experiment_run_id is required but was not provided')
if not isinstance(experiment_run_id, int):
error_msg = (
raise ValueError(
f'experiment_run_id must be an integer, got {type(experiment_run_id).__name__}'
)
workflow.logger.error(error_msg)
raise ValueError(error_msg)
return experiment_run_id
@@ -211,21 +174,17 @@ class TrainModel:
Raises:
Exception: If validation fails (after updating DB status)
"""
validation_input = {
**metadata,
**input_data, # All training params at same level
}
try:
# Execute validation activity
train_params = await workflow.execute_activity_method(
Activities.validate_train_params,
validation_input,
retry_policy=no_retry_policy, # Validation errors are permanent
{
**metadata,
**input_data,
},
retry_policy=no_retry_policy,
start_to_close_timeout=timedelta(seconds=TIMEOUT_VALIDATE_PARAMS),
)
# Validation succeeded: Update status
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
@@ -234,41 +193,23 @@ class TrainModel:
)
return train_params
except Exception as e:
# Validation failed: Update status with error
error_message = str(e)
error_type = type(e).__name__
# Log with rich context for debugging
workflow.logger.error(
f'[VALIDATION_ERROR] Experiment {experiment_run_id} validation failed',
extra={
'step': 'validate_training_parameters',
'experiment_run_id': experiment_run_id,
'error_type': error_type,
'error_message': error_message,
'workflow_id': metadata.get('metadata', {}).get('workflow_id'),
},
)
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.STATUS_WITH_ERROR,
status=ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR,
error_message=error_message,
error_message=self._extract_error_message(e),
)
# Re-raise exception to stop workflow
raise
async def _download_and_train_model(
async def _train_model(
self,
train_params: TrainModelParams,
experiment_run_id: int,
metadata: dict[str, Any],
) -> TrainModelResult:
) -> dict[str, str | None]:
"""
Download file from MinIO and train model.
@@ -287,167 +228,53 @@ class TrainModel:
Raises:
Exception: If download or training fails (after updating DB status)
"""
uploaded_file = None
try:
# Step 1: Download file from MinIO
download_input = {
**metadata,
'bucket_name': train_params.bucket_name,
'file_name': train_params.file_name,
}
uploaded_file = await workflow.execute_activity_method(
Activities.fetch_file_from_minio,
download_input,
retry_policy=network_retry_policy, # Fast retry for network issues
start_to_close_timeout=timedelta(seconds=TIMEOUT_DOWNLOAD_FILE),
)
# Step 2: Train model with downloaded file
train_input = {
**metadata,
'uploaded_file': uploaded_file,
'train_params': train_params,
}
train_result = await workflow.execute_activity_method(
Activities.train_model,
train_input,
retry_policy=no_retry_policy, # Training errors are permanent (bad data)
{
**metadata,
'train_params': train_params,
},
retry_policy=no_retry_policy,
start_to_close_timeout=timedelta(seconds=TIMEOUT_TRAIN_MODEL),
)
# Training succeeded: Update status
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.STATUS,
status=ExperimentStatus.TRAINING_SUCCESS,
)
return train_result
except Exception as e:
# Download or training failed: Update status with error
error_message = str(e)
error_type = type(e).__name__
# Log with rich context for debugging
workflow.logger.error(
f'[TRAINING_ERROR] Experiment {experiment_run_id} training failed',
extra={
'step': 'download_and_train_model',
'experiment_run_id': experiment_run_id,
'experiment_name': train_params.experiment_name,
'bucket_name': train_params.bucket_name,
'file_name': train_params.file_name,
'error_type': error_type,
'error_message': error_message,
'workflow_id': metadata.get('metadata', {}).get('workflow_id'),
},
)
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.STATUS_WITH_ERROR,
status=ExperimentStatus.TRAINING_ERROR,
error_message=error_message,
)
# Re-raise exception to stop workflow
raise
finally:
# Ensure BytesIO is closed
if uploaded_file and hasattr(uploaded_file, 'close'):
uploaded_file.close()
async def _save_model_to_mlflow(
self,
train_result: TrainModelResult,
experiment_run_id: int,
metadata: dict[str, Any],
) -> TrainModelResult:
"""
Save trained model to MLFlow.
This method calls the save_model activity to save the trained model and
its artifacts to MLFlow. On success, updates DB status to MLFLOW_SENT
with MODEL_SAVED type and run_name. On error, updates DB status to
MLFLOW_SEND_ERROR.
Args:
train_result: TrainModelResult from training step
experiment_run_id: Validated experiment run ID
metadata: Workflow execution metadata
Returns:
TrainModelResult: Updated training result with MLFlow run name
Raises:
Exception: If model saving fails (after updating DB status)
"""
try:
# Save model to MLFlow
save_input = {
**metadata,
'train_result': train_result,
}
saved_result = await workflow.execute_activity_method(
Activities.save_model,
save_input,
retry_policy=mlflow_retry_policy, # Retry MLFlow with backoff
start_to_close_timeout=timedelta(seconds=TIMEOUT_SAVE_MODEL),
)
# Model saved successfully: Update status to MLFLOW_SENT with run_name
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.MODEL_SAVED,
status=ExperimentStatus.MLFLOW_SENT,
run_name=saved_result.run_name,
run_name=train_result.get('run_name'),
)
return saved_result
return train_result
except Exception as e:
# Model saving failed: Update status with error
error_message = str(e)
error_type = type(e).__name__
# Mapear flags -> status
# False/False: erro no treino
# True/False: erro ao salvar (MLflow)
# False/True: estado inconsistente, tratar como erro de treino
# True/True: não deveria cair aqui; tratar como erro genérico de treino
status = ExperimentStatus.TRAINING_ERROR
# Log with rich context for debugging
workflow.logger.error(
f'[MLFLOW_ERROR] Experiment {experiment_run_id} model save failed',
extra={
'step': 'save_model_to_mlflow',
'experiment_run_id': experiment_run_id,
'experiment_name': train_result.params.experiment_name,
'run_dir': train_result.run_dir if hasattr(train_result, 'run_dir') else None,
'error_type': error_type,
'error_message': error_message,
'workflow_id': metadata.get('metadata', {}).get('workflow_id'),
},
)
if isinstance(e, ModelTrainingError) and (e.model_trained and not e.model_saved):
status = ExperimentStatus.MLFLOW_SEND_ERROR
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.STATUS_WITH_ERROR,
status=ExperimentStatus.MLFLOW_SEND_ERROR,
error_message=error_message,
status=status,
error_message=self._extract_error_message(e),
)
# Re-raise exception to stop workflow
raise
async def _cleanup_resources(
self,
saved_result: TrainModelResult,
experiment_run_id: int,
run_dir: str,
bucket_name: str,
file_name: str,
metadata: dict[str, Any],
) -> None:
"""
@@ -466,75 +293,33 @@ class TrainModel:
Exception: If cleanup fails (after updating DB status)
"""
try:
# Step 1: Remove temporary run directory via activity (deterministic)
if hasattr(saved_result, 'run_dir') and saved_result.run_dir:
cleanup_input = {
**metadata,
'run_dir': saved_result.run_dir,
}
await workflow.execute_activity_method(
Activities.cleanup_run_directory,
cleanup_input,
retry_policy=filesystem_retry_policy, # Retry filesystem operations
start_to_close_timeout=timedelta(seconds=TIMEOUT_CLEANUP_DIRECTORY),
)
workflow.logger.info(
f'Run directory cleanup completed for experiment {experiment_run_id}'
)
# Step 2: Delete file from MinIO
delete_input = {
**metadata,
'bucket_name': saved_result.params.bucket_name,
'file_name': saved_result.params.file_name,
}
await workflow.execute_activity_method(
Activities.delete_file_from_minio,
delete_input,
retry_policy=network_retry_policy, # Fast retry for network issues
Activities.cleanup_resources,
{
**metadata,
'run_dir': run_dir,
'bucket_name': bucket_name,
'file_name': file_name,
},
retry_policy=network_retry_policy,
start_to_close_timeout=timedelta(seconds=TIMEOUT_DELETE_FILE),
)
# Cleanup succeeded: Update status to FILE_DELETED
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.STATUS,
status=ExperimentStatus.FILE_DELETED,
)
except Exception as e:
# Cleanup failed: Update status with error
error_message = str(e)
error_type = type(e).__name__
# Log with rich context for debugging
workflow.logger.error(
f'[CLEANUP_ERROR] Experiment {experiment_run_id} cleanup failed',
extra={
'step': 'cleanup_resources',
'experiment_run_id': experiment_run_id,
'bucket_name': saved_result.params.bucket_name,
'file_name': saved_result.params.file_name,
'run_dir': saved_result.run_dir if hasattr(saved_result, 'run_dir') else None,
'error_type': error_type,
'error_message': error_message,
'workflow_id': metadata.get('metadata', {}).get('workflow_id'),
},
)
await self._update_experiment_run(
metadata=metadata,
experiment_run_id=experiment_run_id,
update_type=UpdateType.STATUS_WITH_ERROR,
status=ExperimentStatus.FILE_DELETE_ERROR,
error_message=error_message,
error_message=self._extract_error_message(e),
)
# Re-raise exception to stop workflow
raise
async def _update_experiment_run(
@@ -567,17 +352,34 @@ class TrainModel:
'status': status,
}
# Add error_message only if provided
if error_message is not None:
update_input['error_message'] = error_message
# Add run_name only if provided
if run_name is not None:
update_input['run_name'] = run_name
await workflow.execute_activity_method(
Activities.update_experiment_run,
update_input,
retry_policy=database_retry_policy, # Retry DB with exponential backoff
retry_policy=database_retry_policy,
start_to_close_timeout=timedelta(seconds=TIMEOUT_UPDATE_DATABASE),
)
def _extract_error_message(self, exc: Exception) -> str:
message_parts: list[str] = []
seen: set[int] = set()
current: Exception | None = exc
while current and id(current) not in seen:
seen.add(id(current))
text = str(current).strip()
if text and text not in message_parts:
message_parts.append(text)
current = getattr(current, 'cause', None)
if not message_parts:
return repr(exc)
return ' | '.join(message_parts)