Merge pull request #8 from Aignosi/feature/SIENTIAPDE-1255
SIENTIAPDE-1255: Refactor Model Manager for Training Pipeline, Integrate Sientia MLOps Library, and Add Unit Tests
This commit is contained in:
@@ -44,3 +44,5 @@ TIMEOUT_SAVE_MODEL="300" # Save model to MLFlow (5 min for artifac
|
||||
TIMEOUT_CLEANUP_DIRECTORY="60" # Cleanup temporary directory (1 min)
|
||||
TIMEOUT_DELETE_FILE="60" # Delete file from MinIO (1 min)
|
||||
TIMEOUT_UPDATE_DATABASE="30" # Database update operations (30 sec)
|
||||
|
||||
EXTRA_PIP_REQUIREMENTS="git+https://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@github.com/Aignosi/sientia-mlops-library.git"
|
||||
|
||||
16
.github/workflows/quality-gate.yml
vendored
16
.github/workflows/quality-gate.yml
vendored
@@ -164,7 +164,7 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: 'Aignosi'
|
||||
repositories: 'sientia-dataops-library,sientia-mlops-library'
|
||||
repositories: 'sientia-dataops-library'
|
||||
|
||||
- name: Prepare requirements.txt
|
||||
id: prepare-requirements
|
||||
@@ -180,18 +180,6 @@ jobs:
|
||||
run: |
|
||||
git config --global url."https://oauth2:${GH_APP_TOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
|
||||
- name: 🧹 Free Disk Space
|
||||
run: |
|
||||
echo "Disk space before cleanup:"
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune --all --force
|
||||
echo "Disk space after cleanup:"
|
||||
df -h
|
||||
|
||||
- name: 🔧 Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -201,7 +189,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements_prepared.txt', 'requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
|
||||
491
README.md
491
README.md
@@ -1,6 +1,6 @@
|
||||
# Sientia DataOps Model Manager
|
||||
|
||||
A comprehensive AI model management platform for the complete machine learning lifecycle. Handles model training, versioning, deployment, monitoring, and governance. Streamlines MLOps workflows with centralized model registry, automated pipelines, performance tracking, and enterprise-grade compliance features.
|
||||
An enterprise-grade ML model training orchestration platform built on Temporal. Provides robust, scalable workflows for training machine learning models with comprehensive validation, experiment tracking, and automated resource management. Integrates seamlessly with MLFlow for model persistence and PostgreSQL for experiment tracking.
|
||||
|
||||
## 📑 Table of Contents
|
||||
|
||||
@@ -14,16 +14,13 @@ A comprehensive AI model management platform for the complete machine learning l
|
||||
- [Data Flow Architecture](#data-flow-architecture)
|
||||
- [Security Architecture](#security-architecture)
|
||||
- [Workflows](#workflows)
|
||||
- [Predictions Batch Workflow](#1-predictions-batch-workflow-predictions_batchpy)
|
||||
- [Prediction Process Workflow](#2-prediction-process-workflow-prediction_processpy)
|
||||
- [Format and Export Prediction Workflow](#3-format-and-export-prediction-workflow-format_and_export_predictionpy)
|
||||
- [Train Model Workflow](#4-train-model-workflow-train_modelpy)
|
||||
- [Minimal Retrain Workflow](#5-minimal-retrain-workflow-minimal_retrainpy)
|
||||
- [Train Model Workflow](#train-model-workflow-train_modelpy)
|
||||
- [Installation & Setup](#installation--setup)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Environment Setup](#environment-setup)
|
||||
- [Temporal Namespace Setup](#temporal-namespace-setup)
|
||||
- [Local Development Setup](#local-development-setup)
|
||||
- [Port Forward Setup Script](#port-forward-setup-script)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Running the Model Manager Application](#running-the-model-manager-application)
|
||||
- [Running Tests and Coverage](#running-tests-and-coverage)
|
||||
@@ -43,8 +40,7 @@ A comprehensive AI model management platform for the complete machine learning l
|
||||
- [Test Execution](#test-execution)
|
||||
- [Monitoring and Metrics](#monitoring-and-metrics)
|
||||
- [Application Health Metrics](#application-health-metrics)
|
||||
- [Prediction Operation Metrics](#prediction-operation-metrics)
|
||||
- [Data Quality Metrics](#data-quality-metrics)
|
||||
- [Training Metrics](#training-metrics)
|
||||
- [Configuration](#configuration-1)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Workflow Configuration](#workflow-configuration)
|
||||
@@ -67,19 +63,19 @@ A comprehensive AI model management platform for the complete machine learning l
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Batch Prediction Processing**: High-throughput ML model inference using MLFlow models
|
||||
- **Temporal Workflow Orchestration**: Robust workflow management with automatic retry policies and fault tolerance
|
||||
- **Data Quality Gates**: Configurable filtering for data validation, MLFlow API responses, and custom validation rules
|
||||
- **Multi-Model Support**: Flexible ML model management with retention policies and versioning
|
||||
- **Real-time Data Export**: PostgreSQL persistence for data storage
|
||||
- **ML Model Training Pipeline**: Complete training workflow from validation to deployment using MLFlow
|
||||
- **Temporal Workflow Orchestration**: Robust workflow management with granular retry policies and fault tolerance
|
||||
- **Parameter Validation**: Defense-in-depth validation with business rules and type checking
|
||||
- **Experiment Tracking**: Comprehensive status tracking in PostgreSQL database
|
||||
- **Resource Management**: Automatic cleanup of temporary files and storage
|
||||
- **Comprehensive Monitoring**: Prometheus metrics and detailed logging for operational visibility
|
||||
|
||||
### Advanced Capabilities
|
||||
- **Incremental Data Processing**: Timestamp-based data loading to avoid reprocessing
|
||||
- **Configurable Data Retention**: Model retention policies with automatic cleanup
|
||||
- **Granular Retry Policies**: Different strategies for network, training, MLFlow, database, and filesystem operations
|
||||
- **Configurable Timeouts**: Environment variable-based timeouts supporting large training files (up to 200MB)
|
||||
- **Notification System**: Integrated alerting and notification management via MongoDB
|
||||
- **Scalable Architecture**: Kubernetes-ready deployment with horizontal scaling support
|
||||
- **Model Retraining**: Automated model retraining workflows with production model updates
|
||||
- **MLFlow Integration**: Seamless model and artifact persistence to MLFlow tracking server
|
||||
|
||||
### Development & Quality Assurance
|
||||
- **Code Quality Tools**: Ruff (linting/formatting), mypy (type checking), Bandit (security analysis)
|
||||
@@ -135,18 +131,16 @@ The Model Manager system uses a Temporal-based workflow architecture with clear
|
||||
- Health check endpoints for Kubernetes liveness/readiness probes
|
||||
- Graceful shutdown with cleanup procedures
|
||||
- Multi-instance deployment support
|
||||
- Two dedicated task queues: `predictions_batch-queue` and `minimal_retrain-queue`
|
||||
- Dedicated task queue: `train_model-queue` for ML model training workflows
|
||||
|
||||
#### **Workflows (`model_manager/workflows/`)**
|
||||
- **PredictionsBatch**: Main entry point for batch prediction pipelines
|
||||
- **PredictionProcess**: Core prediction pipeline with MLFlow integration
|
||||
- **FormatAndExportPrediction**: Data formatting and export operations
|
||||
- **MinimalRetrain**: Automated model retraining and deployment
|
||||
- **TrainModel**: Complete ML model training pipeline from validation to deployment
|
||||
- **Key Features**:
|
||||
- Temporal workflow definitions with retry policies
|
||||
- Child workflow orchestration and delegation
|
||||
- Comprehensive error handling and recovery
|
||||
- Configurable timeout and retry strategies
|
||||
- Temporal workflow definitions with granular retry policies
|
||||
- Parameter validation with business rules
|
||||
- Comprehensive error handling and status tracking
|
||||
- Configurable timeouts for different operation types
|
||||
- Automatic resource cleanup and management
|
||||
|
||||
#### **Activities (`model_manager/activities/`)**
|
||||
- **Activities**: Main activity orchestrator combining all functionality through multiple inheritance
|
||||
@@ -161,43 +155,45 @@ The Model Manager system uses a Temporal-based workflow architecture with clear
|
||||
- Returns success/failure status with TrainModelResult or error message
|
||||
- No exception raising on failure - allows workflow to handle errors gracefully
|
||||
- Integration with TrainingRepository for business logic separation
|
||||
- **Gates**: Data quality validation and filtering mechanisms
|
||||
- **MLFlow**: Model transformation and prediction operations
|
||||
- **MinIO**: Object storage operations for file management
|
||||
- **MLFlow**: Model saving and artifact management operations
|
||||
- **MinIO**: Object storage operations for training data management
|
||||
- **Key Features**:
|
||||
- Multiple inheritance pattern for unified activity interface
|
||||
- Configurable filter policies and validation rules
|
||||
- MLFlow model serving integration with configurable flavors
|
||||
- Parameter validation with business rules
|
||||
- MLFlow integration for model persistence
|
||||
- Comprehensive error handling and notification integration
|
||||
- Experiment tracking with automatic status management
|
||||
|
||||
#### **Data Services (`model_manager/utils/`)**
|
||||
- **Connectors Config**: Environment variable-based configuration management
|
||||
- **Repository**: Data access layer for MLFlow operations
|
||||
- `model_repository.py`: MLFlow model operations and retraining
|
||||
- **Filters**: Data quality validation and MLFlow response filtering
|
||||
- `conditional_filters.py`: Input data validation filters
|
||||
- `mlflow_filters.py`: MLFlow API response validation filters
|
||||
- **Repository**: Data access layer for training and MLFlow operations
|
||||
- `training_repository.py`: Training business logic and operations
|
||||
- `model_repository.py`: MLFlow artifact generation and model persistence
|
||||
- **Models**: Data models and schemas
|
||||
- `train_model_params.py`: Training parameters model
|
||||
- `train_model_result.py`: Training result model
|
||||
- `experiment_status.py`: Experiment status enum
|
||||
- **Key Features**:
|
||||
- Environment variable-based configuration with sensible defaults
|
||||
- Connection pool management and optimization
|
||||
- Security credential management
|
||||
- Configuration validation and error handling
|
||||
- Support for MLFlow model flavors
|
||||
- Type-safe data models with validation
|
||||
|
||||
### Data Flow Architecture
|
||||
|
||||
#### **1. Batch Prediction Pipeline**
|
||||
#### **Model Training Pipeline**
|
||||
```
|
||||
Input Data (PostgreSQL) → Data Quality Gates → MLFlow Transform →
|
||||
MLFlow Prediction → Response Validation → Export (PostgreSQL)
|
||||
Training Request → Parameter Validation → MinIO Data Download →
|
||||
Model Training → MLFlow Model Save → Resource Cleanup → Status Update
|
||||
```
|
||||
|
||||
#### **2. Model Retraining Pipeline**
|
||||
```
|
||||
Training Data → Model Retraining → Quality Validation →
|
||||
Production Update → Notification & Monitoring
|
||||
```
|
||||
**Key Stages:**
|
||||
1. **Validation**: Experiment run ID and training parameters validation
|
||||
2. **Data Acquisition**: Download training data from MinIO storage
|
||||
3. **Training**: Execute ML model training with validated parameters
|
||||
4. **Persistence**: Save trained model and artifacts to MLFlow
|
||||
5. **Cleanup**: Remove temporary files and update experiment status
|
||||
|
||||
### Security Architecture
|
||||
|
||||
@@ -221,166 +217,7 @@ Production Update → Notification & Monitoring
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. Predictions Batch Workflow (`predictions_batch.py`)
|
||||
|
||||
The **PredictionsBatch** workflow is the main entry point for batch prediction pipelines. It orchestrates the complete prediction process and implements a robust data loading and processing pattern.
|
||||
|
||||
#### Purpose
|
||||
- **Batch Prediction Orchestration**: Coordinates data loading and prediction processing
|
||||
- **Data Preparation**: Loads data using custom SQL queries with configurable schemas
|
||||
- **Workflow Delegation**: Delegates actual prediction processing to the PredictionProcess workflow
|
||||
- **Configuration Management**: Handles model configuration, filters, and retention policies
|
||||
|
||||
#### Execution Flow
|
||||
1. **Data Loading**: Executes custom SQL query to load data from PostgreSQL
|
||||
2. **Input Preparation**: Prepares prediction input with metadata and configuration
|
||||
3. **Workflow Delegation**: Spawns PredictionProcess child workflow for actual processing
|
||||
4. **Error Handling**: Implements comprehensive error handling with retry policies
|
||||
|
||||
#### Key Features
|
||||
- **Custom Query Support**: Flexible SQL-based data loading
|
||||
- **Schema Configuration**: Configurable data schema definitions
|
||||
- **Automatic Retry**: Implements Temporal retry policies for fault tolerance
|
||||
- **Timeout Management**: 60-second timeout for all activities
|
||||
- **Comprehensive Error Handling**: Detailed error reporting and notification integration
|
||||
|
||||
#### Input Parameters
|
||||
```json
|
||||
{
|
||||
"schedule_name": "hourly_predictions",
|
||||
"model_name": "temperature_prediction_model",
|
||||
"model_id": "temp_pred_001",
|
||||
"query": "SELECT * FROM sensor_data WHERE timestamp > NOW() - INTERVAL '1 hour'",
|
||||
"schema": {
|
||||
"timestamp": "datetime",
|
||||
"temperature": "float",
|
||||
"humidity": "float"
|
||||
},
|
||||
"table_name": "predictions",
|
||||
"input_filters": {
|
||||
"EMPTY_DATA": {"POLICY": "STOP"}
|
||||
},
|
||||
"mlflow_transform_filters": {
|
||||
"API_ERROR": {"POLICY": "STOP"}
|
||||
},
|
||||
"mlflow_predict_filters": {
|
||||
"API_ERROR": {"POLICY": "STOP"}
|
||||
},
|
||||
"model_retention": 60,
|
||||
"path_priority": ["STOP", "CONTINUE", "REPEAT"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Architecture Diagram
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[1. load_custom_query] --> B[2. prediction_process 🔃]
|
||||
|
||||
A -.-> Database[(Database)]
|
||||
```
|
||||
|
||||
### 2. Prediction Process Workflow (`prediction_process.py`)
|
||||
|
||||
The **PredictionProcess** workflow implements the core prediction pipeline for ML model inference. It handles data quality validation, MLFlow model interactions, and prediction processing.
|
||||
|
||||
#### Purpose
|
||||
- **Data Quality Validation**: Applies configurable filters for data integrity
|
||||
- **MLFlow Integration**: Manages model transformation and prediction requests
|
||||
- **Response Validation**: Filters MLFlow API responses for quality assurance
|
||||
- **Prediction Export**: Delegates prediction formatting and export operations
|
||||
|
||||
#### Execution Flow
|
||||
1. **Timestamp Retrieval**: Gets the last processed timestamp for incremental processing
|
||||
2. **Input Data Gate**: Applies configured filters for data quality validation
|
||||
3. **Path Decision**: Determines processing path based on filter results
|
||||
4. **MLFlow Transform**: Requests data transformation using MLFlow models
|
||||
5. **Response Validation**: Filters transform responses for quality assurance
|
||||
6. **MLFlow Prediction**: Executes prediction using transformed data
|
||||
7. **Content Validation**: Filters prediction responses for final quality check
|
||||
8. **Export Delegation**: Delegates to FormatAndExportPrediction workflow
|
||||
|
||||
#### Key Features
|
||||
- **Configurable Quality Gates**: Multiple filter types with policy-based configuration
|
||||
- **Flexible Path Handling**: Configurable decision paths (STOP, CONTINUE, REPEAT)
|
||||
- **MLFlow Integration**: Comprehensive model management and inference
|
||||
- **Incremental Processing**: Timestamp-based data processing optimization
|
||||
- **Comprehensive Monitoring**: Detailed metrics and error reporting
|
||||
|
||||
#### Input Parameters
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"schedule_name": "hourly_predictions",
|
||||
"model_name": "temperature_prediction_model",
|
||||
"model_id": "temp_pred_001",
|
||||
"workflow_name": "predictions_batch"
|
||||
},
|
||||
"data": {...},
|
||||
"schema": {...},
|
||||
"table_name": "predictions",
|
||||
"model_id": "temp_pred_001",
|
||||
"model_name": "temperature_prediction_model",
|
||||
"input_filters": {
|
||||
"EMPTY_DATA": {"POLICY": "STOP"},
|
||||
"SPECIFIC_VARIABLES_NULL_VALUES": {
|
||||
"POLICY": "STOP",
|
||||
"config": {"variables": ["temperature", "humidity"]}
|
||||
}
|
||||
},
|
||||
"mlflow_transform_filters": {
|
||||
"API_ERROR": {"POLICY": "STOP"}
|
||||
},
|
||||
"mlflow_predict_filters": {
|
||||
"API_ERROR": {"POLICY": "STOP"},
|
||||
"NAN_VALUES": {"POLICY": "STOP"}
|
||||
},
|
||||
"model_retention": 60,
|
||||
"path_priority": ["STOP", "CONTINUE", "REPEAT"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Architecture Diagram
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[1. get_last_timestamp] --> B[2. input_gate] --> C[3. request_transform] --> D[4. mlflow_response_gate] --> E[5. mlflow_content_gate] --> F[6. request_predict] --> G[7. mlflow_response_gate] --> H[8. format_and_export_prediction🔃]
|
||||
|
||||
C -.-> MLFlow[MLFlow]
|
||||
F -.-> MLFlow[MLFlow]
|
||||
G -.-> Filters[MLFlow Filters]
|
||||
```
|
||||
|
||||
### 3. Format and Export Prediction Workflow (`format_and_export_prediction.py`)
|
||||
|
||||
The **FormatAndExportPrediction** workflow handles prediction data formatting and export operations to multiple destinations.
|
||||
|
||||
#### Purpose
|
||||
- **Data Formatting**: Formats prediction data for database storage
|
||||
- **PostgreSQL Export**: Persists predictions to database with metrics
|
||||
- **Metrics Recording**: Tracks export operations and performance metrics
|
||||
|
||||
#### Execution Flow
|
||||
1. **Path Decision**: Determines formatting path based on configuration
|
||||
2. **Data Formatting**: Formats prediction data for specific output requirements
|
||||
3. **PostgreSQL Export**: Writes formatted predictions to database
|
||||
4. **Metrics Recording**: Records export performance and success metrics
|
||||
|
||||
#### Key Features
|
||||
- **Flexible Formatting**: Configurable output formats for different destinations
|
||||
- **Database Export**: PostgreSQL integration for data persistence
|
||||
- **Performance Monitoring**: Comprehensive metrics for export operations
|
||||
- **Error Handling**: Robust error handling with notification integration
|
||||
|
||||
#### Architecture Diagram
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[1. format_prediction/format_default_prediction] --> B[2. export_data_to_postgres] --> C[3. write_metrics]
|
||||
|
||||
A -.-> Format[Data Formatting]
|
||||
B -.-> PostgreSQL[(PostgreSQL)]
|
||||
C -.-> Prometheus[Prometheus]
|
||||
```
|
||||
|
||||
### 4. Train Model Workflow (`train_model.py`)
|
||||
### Train Model Workflow (`train_model.py`)
|
||||
|
||||
The **TrainModel** workflow orchestrates the complete ML model training pipeline from parameter validation through model saving and cleanup.
|
||||
|
||||
@@ -472,34 +309,6 @@ The workflow validates 10 business rules beyond type checking:
|
||||
5. **target_variable**: Must be in variable_columns
|
||||
6. **bucket_name, file_name, experiment_name**: Cannot be empty or whitespace
|
||||
|
||||
### 5. Minimal Retrain Workflow (`minimal_retrain.py`)
|
||||
|
||||
The **MinimalRetrain** workflow handles automated model retraining and production model updates.
|
||||
|
||||
#### Purpose
|
||||
- **Model Retraining**: Automates ML model retraining processes
|
||||
- **Production Updates**: Manages production model version updates
|
||||
- **Data Export**: Exports training data for model development
|
||||
- **Quality Assurance**: Ensures model quality before production deployment
|
||||
|
||||
#### Execution Flow
|
||||
1. **Data Loading**: Loads training data using custom queries
|
||||
2. **Model Retraining**: Executes model retraining process
|
||||
3. **Quality Validation**: Validates retrained model performance
|
||||
4. **Production Update**: Updates production model if quality criteria met
|
||||
5. **Data Export**: Exports training data for analysis
|
||||
|
||||
#### Architecture Diagram
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[1. load_custom_query] --> B[2. retrain_model] --> C[3. update_production_model] --> D[4. export_data_to_postgres]
|
||||
|
||||
A -.-> Database[(Database)]
|
||||
B -.-> MLFlow[MLFlow]
|
||||
C -.-> MLFlow[MLFlow]
|
||||
D -.-> PostgreSQL[(PostgreSQL)]
|
||||
```
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Prerequisites
|
||||
@@ -708,17 +517,82 @@ kubectl exec -n temporal <temporal-admin-tools-pod-name> -- \
|
||||
|
||||
5. **Configure external dependencies**
|
||||
|
||||
You'll need to set up port forwarding or connections to external services. For example:
|
||||
The Model Manager requires connections to several external services. For local development, you can use the provided port-forward script to establish connections to services running in your Kubernetes cluster.
|
||||
|
||||
```bash
|
||||
# Port forwarding from Kubernetes cluster
|
||||
kubectl port-forward svc/postgresql 5432:5432
|
||||
kubectl port-forward svc/mlflow 5000:5000
|
||||
kubectl port-forward svc/mongodb 27017:27017
|
||||
#### Port Forward Setup Script
|
||||
|
||||
# Or connect to external services
|
||||
# Ensure services are accessible on localhost with appropriate ports
|
||||
```
|
||||
The `setup_port_forwards.sh` script automates the creation of port forwards to all required services:
|
||||
|
||||
**Features:**
|
||||
- 🔄 **Automatic Cleanup**: Kills existing port-forward jobs for the same services
|
||||
- ✅ **Port Validation**: Checks if ports are available before creating forwards
|
||||
- 🛡️ **Safe Execution**: Stops if any port is already in use by another process
|
||||
- 📊 **Clear Output**: Color-coded status messages and service information
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
# Make script executable (first time only)
|
||||
chmod +x setup_port_forwards.sh
|
||||
|
||||
# Run the script
|
||||
./setup_port_forwards.sh
|
||||
```
|
||||
|
||||
**Services and Ports:**
|
||||
|
||||
| Local Port | Service | Description | Namespace |
|
||||
|------------|---------|-------------|-----------|
|
||||
| `5432` | `paradedb-rw` | PostgreSQL Database | `paradedb` |
|
||||
| `45249` | `sientia-tracker-mlflow-tracking` | MLflow Tracking Server | `sientia-tracker` |
|
||||
| `37463` | `temporal-frontend` | Temporal gRPC API | `temporal` |
|
||||
| `8080` | `temporal-web` | Temporal Web UI | `temporal` |
|
||||
| `42297` | `my-release-mongodb` | MongoDB Database | `mongodb` |
|
||||
| `36577` | `minio` | MinIO Object Storage | `minio` |
|
||||
|
||||
**Managing Port Forwards:**
|
||||
|
||||
```bash
|
||||
# View active port forwards
|
||||
jobs -l
|
||||
|
||||
# Stop all port forwards
|
||||
jobs -p | xargs kill
|
||||
|
||||
# Stop and restart (using the script)
|
||||
./setup_port_forwards.sh
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
If you encounter port conflicts:
|
||||
1. The script will show which ports are in use
|
||||
2. Stop the conflicting process or use the script to kill existing port-forwards
|
||||
3. Run the script again
|
||||
|
||||
**Manual Port Forwarding:**
|
||||
|
||||
If you prefer manual control or need different ports:
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
kubectl -n paradedb port-forward svc/paradedb-rw 5432:5432 &
|
||||
|
||||
# MLflow
|
||||
kubectl -n sientia-tracker port-forward svc/sientia-tracker-mlflow-tracking 45249:80 &
|
||||
|
||||
# Temporal
|
||||
kubectl -n temporal port-forward svc/temporal-frontend 37463:7233 &
|
||||
|
||||
# Temporal UI
|
||||
kubectl -n temporal port-forward svc/temporal-web 8080:8080 &
|
||||
|
||||
# MongoDB
|
||||
kubectl -n mongodb port-forward svc/my-release-mongodb 42297:27017 &
|
||||
|
||||
# MinIO
|
||||
kubectl -n minio port-forward svc/minio 36577:9000 &
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
@@ -920,8 +794,8 @@ pip install pytest pytest-cov pytest-asyncio
|
||||
pytest --cov=model_manager --cov-report=html
|
||||
|
||||
# Run specific test modules
|
||||
pytest tests/activities/test_gates.py
|
||||
pytest tests/workflows/test_predictions_batch.py
|
||||
pytest tests/activities/test_training.py
|
||||
pytest tests/workflows/test_train_model.py
|
||||
```
|
||||
|
||||
## Monitoring and Metrics
|
||||
@@ -932,19 +806,10 @@ The Model Manager system exposes comprehensive Prometheus metrics for operationa
|
||||
- `app_up`: Application health status (1=healthy, 0=unhealthy)
|
||||
- Labels: `pod_id`
|
||||
|
||||
### Prediction Operation Metrics
|
||||
- `model_manager_predictions_written_count`: Counter for successful prediction exports
|
||||
- Labels: `pod_id`, `model_name`, `pipeline_name`
|
||||
- `model_manager_prediction_confidence_monitor`: Gauge for current prediction confidence levels
|
||||
- Labels: `pod_id`, `model_name`, `pipeline_name`
|
||||
- `model_manager_prediction_response_time_monitor`: Histogram for prediction response times
|
||||
- Labels: `pod_id`, `model_name`, `pipeline_name`
|
||||
- Buckets: [0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]
|
||||
|
||||
### Data Quality Metrics
|
||||
- Filter pass/fail rates through notification system
|
||||
- MLFlow API response validation metrics
|
||||
- Data quality gate performance tracking
|
||||
### Training Metrics
|
||||
- Training success/failure rates through notification system
|
||||
- Model save performance metrics
|
||||
- Experiment status tracking
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -1001,94 +866,6 @@ These timeouts control how long each activity in the training workflow can run b
|
||||
|
||||
**Note**: These timeouts can be adjusted based on your infrastructure performance and file sizes. If you're processing files larger than 200MB or have slower network/compute resources, increase these values accordingly.
|
||||
|
||||
### Workflow Configuration
|
||||
|
||||
MongoDB pipeline configuration:
|
||||
|
||||
#### Predictions Batch Workflow configuration sample
|
||||
|
||||
This is the configuration for the Predictions Batch Workflow, to be inserted into the MongoDB pipeline collection.
|
||||
|
||||
```json
|
||||
{
|
||||
"schedule_name": "laborious-orchestrated-pipeline",
|
||||
"model_id": "1",
|
||||
"workflow_type": "predictions_batch",
|
||||
"frequency": "30s",
|
||||
"max_retry_policy": 1,
|
||||
"query": "select * from sientia_data.laborious_data where model_id = 1 and \"timestamp\" > NOW() - INTERVAL '5 minutes' order by \"timestamp\" desc limit 30;",
|
||||
"write_tags": [
|
||||
{
|
||||
"server_id": "server1",
|
||||
"type": "prediction",
|
||||
"addr": "ns=2;i=5",
|
||||
"data_type": "double"
|
||||
},
|
||||
{
|
||||
"server_id": "server1",
|
||||
"type": "confidence",
|
||||
"addr": "ns=2;i=6",
|
||||
"data_type": "double"
|
||||
}
|
||||
],
|
||||
"input_filters": {
|
||||
"EMPTY_DATA": {"POLICY": "STOP"},
|
||||
"SPECIFIC_VARIABLES_NULL_VALUES": {
|
||||
"POLICY": "CONTINUE",
|
||||
"config": {"variables": ["Counter"]}
|
||||
}
|
||||
},
|
||||
"mlflow_transform_filters": {
|
||||
"API_ERROR": {"POLICY": "REPEAT"},
|
||||
"NAN_VALUES": {"POLICY": "STOP"}
|
||||
},
|
||||
"mlflow_predict_filters": {
|
||||
"API_ERROR": {"POLICY": "CONTINUE"}
|
||||
},
|
||||
"path_priority": ["STOP", "CONTINUE", "REPEAT"],
|
||||
"active": true,
|
||||
"updated_at": {
|
||||
"$date": "2025-09-16T10:00:00.000Z"
|
||||
},
|
||||
"datetime_columns": ["timestamp", "created_at"],
|
||||
"predictions_storage_policy": "lts:1"
|
||||
}
|
||||
```
|
||||
|
||||
This is the configuration created by the Orchestrator in Temporal.
|
||||
|
||||
```json
|
||||
{
|
||||
"datetime_columns":["timestamp","created_at"],
|
||||
"frequency":"15m",
|
||||
"input_filters":{"EMPTY_DATA":{"config":{},"policy":"STOP"}},
|
||||
"max_retry_policy":1,
|
||||
"mlflow_predict_filters":{"API_ERROR":{"config":{},"policy":"CONTINUE"}},
|
||||
"mlflow_transform_filters":{
|
||||
"API_ERROR":{"config":{},"policy":"CONTINUE"},
|
||||
"EMPTY_DATA":{"config":{},"policy":"STOP"}
|
||||
},
|
||||
"model_config":{
|
||||
"is_compressed":true,
|
||||
"predict_flavor":"pyfunc",
|
||||
"retention_minutes":60,
|
||||
"retention_target":"artifact",
|
||||
"transform_function_keyword":"transform"
|
||||
},
|
||||
"model_id":"352",
|
||||
"model_name":"courier",
|
||||
"path_priority":["STOP","CONTINUE","REPEAT"],
|
||||
"predictions_storage_policy":"lts:1",
|
||||
"query":"select * from sientia_data.laborious_data where model_id = 352 order by \"timestamp\" desc limit 300;",
|
||||
"retention_time":3600,
|
||||
"schedule_name":"laborious-courier",
|
||||
"schema":"sientia_data",
|
||||
"table_name":"predictions",
|
||||
"updated_at":"2025-09-12 19:35:01.600000+0000",
|
||||
"workflow_type":"predictions_batch"
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Quality & Testing
|
||||
@@ -1102,7 +879,7 @@ The project maintains **99%+ code coverage** with comprehensive unit and integra
|
||||
pytest tests/ --cov=model_manager --cov-report=term-missing --cov-report=xml --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/activities/test_gates.py -v
|
||||
pytest tests/activities/test_training.py -v
|
||||
|
||||
# Run with coverage visualization
|
||||
pytest tests/ --cov=model_manager --cov-report=xml
|
||||
@@ -1163,30 +940,28 @@ model_manager/
|
||||
├── activities/ # Temporal activity implementations
|
||||
│ ├── __init__.py
|
||||
│ ├── activities.py # Main activities orchestrator (combines all activities)
|
||||
│ ├── gates.py # Data quality gates and filtering logic
|
||||
│ ├── experiment_tracking.py # Experiment status tracking and database operations
|
||||
│ ├── training.py # ML model training operations
|
||||
│ ├── minio.py # MinIO object storage operations
|
||||
│ └── mlflow.py # MLFlow model operations (predict/transform)
|
||||
│ └── mlflow.py # MLFlow model saving and artifact management
|
||||
├── workflows/ # Temporal workflow definitions
|
||||
│ ├── __init__.py
|
||||
│ ├── predictions_batch.py # Main batch prediction workflow entry point
|
||||
│ ├── minimal_retrain.py # Model retraining workflow
|
||||
│ └── sub_workflows/ # Sub-workflow implementations
|
||||
│ ├── __init__.py
|
||||
│ ├── prediction_process.py # Core prediction pipeline
|
||||
│ └── format_and_export_prediction.py # Data export workflow
|
||||
│ └── train_model.py # Complete ML model training workflow
|
||||
├── worker/ # Worker implementation
|
||||
│ ├── __init__.py
|
||||
│ └── worker.py # Main worker orchestrator (Temporal client setup)
|
||||
├── utils/ # Utility functions and helpers
|
||||
│ ├── __init__.py
|
||||
│ ├── connectors_config.py # Environment-based configuration builders
|
||||
│ ├── filters/ # Data quality validation filters
|
||||
│ ├── models/ # Data models and schemas
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── conditional_filters.py # Input data validation filters
|
||||
│ │ └── mlflow_filters.py # MLFlow response validation filters
|
||||
│ │ ├── train_model_params.py # Training parameters model
|
||||
│ │ ├── train_model_result.py # Training result model
|
||||
│ │ └── experiment_status.py # Experiment status enum
|
||||
│ └── repository/ # Data access layer
|
||||
│ ├── __init__.py
|
||||
│ └── model_repository.py # MLFlow model operations and retraining
|
||||
│ ├── training_repository.py # Training business logic
|
||||
│ └── model_repository.py # MLFlow artifact management
|
||||
├── metrics.py # Prometheus metrics definitions
|
||||
└── __init__.py
|
||||
```
|
||||
@@ -1237,7 +1012,7 @@ model_manager/
|
||||
|
||||
4. **Workflow Execution Failures**
|
||||
- Review activity error logs and notifications
|
||||
- Check data quality filter configurations
|
||||
- Check training parameter validation errors
|
||||
- Verify input data format and required fields
|
||||
|
||||
### Debug Mode
|
||||
|
||||
@@ -7,13 +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.gates import Gates
|
||||
from model_manager.activities.minio import MinIO
|
||||
from model_manager.activities.mlflow import MLFlow
|
||||
from model_manager.activities.training import Training
|
||||
|
||||
|
||||
class Activities(ExperimentTracking, MLFlow, MinIO, Gates, Training):
|
||||
class Activities(ExperimentTracking, MLFlow, MinIO, Training):
|
||||
"""
|
||||
Main activities orchestrator for the Model Manager system.
|
||||
|
||||
@@ -23,9 +22,8 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Gates, Training):
|
||||
|
||||
The class implements multiple inheritance to combine specialized functionality:
|
||||
- ExperimentTracking: ML experiment lifecycle tracking and database operations (extends Postgres)
|
||||
- MLFlow: Model inference and transformation operations
|
||||
- MLFlow: Model saving and artifact management operations
|
||||
- MinIO: Object storage operations (file upload/download/delete)
|
||||
- Gates: Data quality validation and filtering mechanisms
|
||||
- Training: ML model training operations (extends BaseActivity)
|
||||
|
||||
Attributes:
|
||||
@@ -102,10 +100,28 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Gates, Training):
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
Gates.__init__(self, logger=logger, notification_handler=notification_handler)
|
||||
|
||||
Training.__init__(self, logger=logger, notification_handler=notification_handler)
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Destructor to safely handle cleanup during garbage collection.
|
||||
|
||||
This prevents AttributeError when the parent Postgres.__del__ tries to access
|
||||
self.engine in objects with multiple inheritance. Only attempts cleanup if
|
||||
the engine attribute exists.
|
||||
"""
|
||||
# Only call parent __del__ if engine attribute exists
|
||||
# This prevents AttributeError in multiple inheritance scenarios
|
||||
if hasattr(self, 'engine'):
|
||||
try:
|
||||
# Call parent class __del__ if it exists
|
||||
if hasattr(super(), '__del__'):
|
||||
super().__del__()
|
||||
except Exception: # noqa: S110, BLE001
|
||||
# Silently ignore errors during garbage collection
|
||||
# Logging here could cause issues if logger is already destroyed
|
||||
pass
|
||||
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Gracefully shutdown all activities and clean up resources.
|
||||
@@ -116,5 +132,7 @@ class Activities(ExperimentTracking, MLFlow, MinIO, Gates, Training):
|
||||
|
||||
The method should be called before the application terminates to ensure
|
||||
proper resource cleanup and prevent resource leaks.
|
||||
|
||||
Prefer calling this method explicitly rather than relying on __del__.
|
||||
"""
|
||||
ExperimentTracking.close(self)
|
||||
|
||||
@@ -88,6 +88,23 @@ class ExperimentTracking(Postgres):
|
||||
self.logger = logger
|
||||
self.notification_handler = notification_handler
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Destructor to safely handle cleanup during garbage collection.
|
||||
|
||||
This prevents AttributeError when used in multiple inheritance scenarios
|
||||
where the parent Postgres.__del__ might be called on objects without
|
||||
the engine attribute.
|
||||
"""
|
||||
# Only call parent __del__ if engine attribute exists
|
||||
if hasattr(self, 'engine'):
|
||||
try:
|
||||
if hasattr(super(), '__del__'):
|
||||
super().__del__()
|
||||
except Exception: # noqa: S110, BLE001
|
||||
# Silently ignore errors during garbage collection
|
||||
pass
|
||||
|
||||
@activity.defn(name='update_experiment_run')
|
||||
async def update_experiment_run(self, input_data: dict[str, Any]) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,583 +0,0 @@
|
||||
from temporalio import activity, workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from pandas import DataFrame
|
||||
from sientia_do.formatters import create_sample_dict
|
||||
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 sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ, now
|
||||
|
||||
from model_manager import metrics
|
||||
from model_manager.utils.filters.conditional_filters import (
|
||||
filter_empty_data,
|
||||
filter_specific_variables_null_values,
|
||||
)
|
||||
from model_manager.utils.filters.mlflow_filters import api_error_filter, nan_values_filter
|
||||
|
||||
# Input filter function mappings
|
||||
input_filter_functions = {
|
||||
'SPECIFIC_VARIABLES_NULL_VALUES': filter_specific_variables_null_values,
|
||||
'EMPTY_DATA': filter_empty_data,
|
||||
'path_confidence': {'STOP': -1, 'CONTINUE': 2, 'REPEAT': -1},
|
||||
}
|
||||
|
||||
# MLFlow response filter function mappings
|
||||
mlflow_response_filter_functions = {
|
||||
'API_ERROR': api_error_filter,
|
||||
'path_confidence': {'STOP': -1, 'CONTINUE': 10, 'REPEAT': -1},
|
||||
}
|
||||
|
||||
# MLFlow content filter function mappings
|
||||
mlflow_content_filter_functions = {
|
||||
'NAN_VALUES': nan_values_filter,
|
||||
'EMPTY_DATA': filter_empty_data,
|
||||
'path_confidence': {'STOP': -1, 'CONTINUE': 18, 'REPEAT': -1},
|
||||
}
|
||||
|
||||
|
||||
class Gates(BaseActivity):
|
||||
"""
|
||||
Data quality gates and filtering activities for the Model Manager system.
|
||||
|
||||
This class implements comprehensive data quality validation and filtering
|
||||
mechanisms that can be applied at different stages of the prediction pipeline.
|
||||
It provides configurable filters with policy-based decision making to ensure
|
||||
data integrity and quality throughout the ML workflow.
|
||||
|
||||
The class supports multiple filter types and implements a flexible policy
|
||||
system that can be configured for different validation requirements. Each
|
||||
filter returns a path decision (STOP, CONTINUE, REPEAT) along with confidence
|
||||
scores and detailed comments for monitoring and debugging.
|
||||
|
||||
Attributes:
|
||||
input_filter_functions (dict): Mapping of input filter names to functions
|
||||
mlflow_response_filter_functions (dict): Mapping of MLFlow response filter names to functions
|
||||
mlflow_content_filter_functions (dict): Mapping of MLFlow content filter names to functions
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Logger, notification_handler: NotificationHandler):
|
||||
"""
|
||||
Initialize data quality gates with logging and notification capabilities.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for observability and debugging
|
||||
notification_handler: Notification handler for alerts and monitoring
|
||||
|
||||
Raises:
|
||||
Exception: If BaseActivity initialization fails
|
||||
"""
|
||||
BaseActivity.__init__(self, logger, notification_handler, set_error_counter=True)
|
||||
|
||||
@activity.defn(name='input_gate')
|
||||
async def input_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
||||
"""
|
||||
Apply input data quality filters and validation.
|
||||
|
||||
This activity validates input data quality using configurable filters
|
||||
before proceeding with ML operations. It applies multiple filter types
|
||||
and returns a path decision based on the filter results and configured
|
||||
policies.
|
||||
|
||||
The method implements a comprehensive filtering system that:
|
||||
1. Applies configured filters to input data
|
||||
2. Evaluates filter results against policy configurations
|
||||
3. Determines appropriate path decisions (STOP, CONTINUE, REPEAT)
|
||||
4. Provides confidence scores and detailed comments
|
||||
5. Handles errors gracefully with notification integration
|
||||
|
||||
Args:
|
||||
input_data: Configuration and data for input validation
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- filters (dict): Filter configuration and policies
|
||||
- data (dict): Input data to validate
|
||||
- path_priority (list[str]): Priority order for path decisions
|
||||
|
||||
Returns:
|
||||
tuple: (path_flag, confidence, comment)
|
||||
- path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None)
|
||||
- confidence (int): Confidence score for the decision
|
||||
- comment (str): Detailed explanation of the decision
|
||||
|
||||
Raises:
|
||||
Exception: If filter execution fails or configuration is invalid
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
|
||||
self.info('Performing input gate...', metadata)
|
||||
|
||||
filters = input_data['filters']
|
||||
data = DataFrame(input_data['data'])
|
||||
path_priority = input_data['path_priority']
|
||||
|
||||
filter_output = []
|
||||
|
||||
self.debug(f'Input data: {data.head(5).to_string()}', metadata)
|
||||
self.debug(f'Filters: {filters}', metadata)
|
||||
|
||||
# Apply each configured filter
|
||||
for fil, config in filters.items():
|
||||
if fil not in input_filter_functions:
|
||||
self.error(f'Filter {fil} not found', metadata)
|
||||
continue
|
||||
try:
|
||||
if input_filter_functions[fil](data, config['config']): # type: ignore[operator]
|
||||
self.debug(f'Data not passed the input filter {fil}:{config}', metadata)
|
||||
filter_output.append(config['policy']) # type: ignore[index]
|
||||
except Exception as e: # noqa: BLE001
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id=f'INTPUT_GATE_ERROR__{fil}',
|
||||
message=f'Error in filter {fil}:{config}: \n {e}',
|
||||
block='input_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
|
||||
for path_flag in path_priority:
|
||||
if path_flag in filter_output:
|
||||
self.info(f'Input gate result: {path_flag}', metadata)
|
||||
return (
|
||||
path_flag,
|
||||
input_filter_functions['path_confidence'][path_flag], # type: ignore[index]
|
||||
'Input data with bad quality',
|
||||
)
|
||||
|
||||
self.info('Nothing was filtered by the input gate', metadata)
|
||||
return None, 0, ''
|
||||
|
||||
@activity.defn(name='mlflow_response_gate')
|
||||
async def mlflow_response_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
||||
"""
|
||||
Validate MLFlow API response quality and integrity.
|
||||
|
||||
This activity validates MLFlow API responses to ensure they meet quality
|
||||
standards before proceeding with further processing. It applies response-specific
|
||||
filters and determines appropriate path decisions based on response quality.
|
||||
|
||||
The method implements response validation that:
|
||||
1. Applies MLFlow response-specific filters
|
||||
2. Evaluates API response quality and integrity
|
||||
3. Determines path decisions based on response validation results
|
||||
4. Provides confidence scores and detailed validation comments
|
||||
5. Handles API errors and response validation failures
|
||||
|
||||
Args:
|
||||
input_data: Configuration and data for response validation
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- filters (dict): Response filter configuration and policies
|
||||
- data (dict): MLFlow API response data to validate
|
||||
- type (str): Type of MLFlow operation (transform, predict)
|
||||
- path_priority (list[str]): Priority order for path decisions
|
||||
|
||||
Returns:
|
||||
tuple: (path_flag, confidence, comment)
|
||||
- path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None)
|
||||
- confidence (int): Confidence score for the decision
|
||||
- comment (str): Detailed explanation of the decision
|
||||
|
||||
Raises:
|
||||
Exception: If response validation fails or configuration is invalid
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
self.info('Performing mlflow response gate...', metadata)
|
||||
|
||||
filters = input_data['filters']
|
||||
data = input_data['data']
|
||||
gate_type = input_data['type']
|
||||
path_priority = input_data['path_priority']
|
||||
|
||||
filter_output = []
|
||||
|
||||
self.debug(f'Input data: \n {create_sample_dict(data, max_items=5, max_depth=2)}', metadata)
|
||||
self.debug(f'Filters: {filters}', metadata)
|
||||
|
||||
comments = []
|
||||
for fil, config in filters.items():
|
||||
if fil not in mlflow_response_filter_functions:
|
||||
self.error(f'Filter {fil} not found', metadata)
|
||||
continue
|
||||
try:
|
||||
if mlflow_response_filter_functions[fil](data, config): # type: ignore[operator]
|
||||
filter_output.append(config['policy']) # type: ignore[index]
|
||||
comments.append(data['content']['message'])
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id=f'{gate_type.upper()}_GATE_RESPONSE_FILTER__{fil}',
|
||||
message=data['content']['message'],
|
||||
block='mlflow_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=data['content']['traceback'],
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id=f'MLFLOW_GATE_RESPONSE_FILTER__{fil}',
|
||||
message=f'Error in filter {fil}:{config}: \n {e}',
|
||||
block='mlflow_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
|
||||
for path_flag in path_priority:
|
||||
if path_flag in filter_output:
|
||||
self.info(f'Mlflow response gate result: {path_flag}', metadata)
|
||||
return (
|
||||
path_flag,
|
||||
mlflow_response_filter_functions['path_confidence'][path_flag], # type: ignore[index]
|
||||
', '.join(comments),
|
||||
)
|
||||
|
||||
self.info('Nothing was filtered by the mlflow response gate', metadata)
|
||||
return None, 0, ''
|
||||
|
||||
@activity.defn(name='mlflow_content_gate')
|
||||
async def mlflow_content_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
||||
"""
|
||||
Validate MLFlow prediction content quality and integrity.
|
||||
|
||||
This activity validates the content of MLFlow predictions to ensure they
|
||||
meet quality standards before export and persistence. It applies content-specific
|
||||
filters and determines appropriate path decisions based on content quality.
|
||||
|
||||
The method implements content validation that:
|
||||
1. Applies MLFlow content-specific filters
|
||||
2. Evaluates prediction content quality and integrity
|
||||
3. Determines path decisions based on content validation results
|
||||
4. Provides confidence scores and detailed validation comments
|
||||
5. Handles content validation failures and quality issues
|
||||
|
||||
Args:
|
||||
input_data: Configuration and data for content validation
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- filters (dict): Content filter configuration and policies
|
||||
- data (dict): MLFlow prediction content to validate
|
||||
- type (str): Type of MLFlow operation (transform, predict)
|
||||
- path_priority (list[str]): Priority order for path decisions
|
||||
|
||||
Returns:
|
||||
tuple: (path_flag, confidence, comment)
|
||||
- path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None)
|
||||
- confidence (int): Confidence score for the decision
|
||||
- comment (str): Detailed explanation of the decision
|
||||
|
||||
Raises:
|
||||
Exception: If content validation fails or configuration is invalid
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
self.info('Performing mlflow content gate...', metadata)
|
||||
|
||||
filters = input_data['filters']
|
||||
data = DataFrame(input_data['data'])
|
||||
gate_type = input_data['type']
|
||||
path_priority = input_data['path_priority']
|
||||
|
||||
filter_output = []
|
||||
|
||||
self.debug(f'Input data:\n {data.head(5).to_string()}', metadata)
|
||||
self.debug(f'Filters: \n {create_sample_dict(filters)}', metadata)
|
||||
|
||||
for fil, config in filters.items():
|
||||
if fil not in mlflow_content_filter_functions:
|
||||
continue
|
||||
try:
|
||||
if mlflow_content_filter_functions[fil](data, config): # type: ignore[operator]
|
||||
filter_output.append(config['policy']) # type: ignore[index]
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id=f'{gate_type.upper()}_GATE_CONTENT_FILTER__{fil}',
|
||||
message=f'Data not passed the content filter {fil}:{config}',
|
||||
block='mlflow_gate',
|
||||
level=NotificationLevel.WARNING,
|
||||
attachment_content=data.to_string(),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id=f'MLFLOW_GATE_CONTENT_FILTER__{fil}',
|
||||
message=f'Error in filter {fil}:{config}: \n {e}',
|
||||
block='mlflow_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
|
||||
for path_flag in path_priority:
|
||||
if path_flag in filter_output:
|
||||
self.info(f'Mlflow content gate result: {path_flag}', metadata)
|
||||
return (
|
||||
path_flag,
|
||||
mlflow_content_filter_functions['path_confidence'][path_flag], # type: ignore[index]
|
||||
'Transformed data not passed the content filter',
|
||||
)
|
||||
|
||||
self.info('Nothing was filtered by the mlflow content gate', metadata)
|
||||
return None, 0, ''
|
||||
|
||||
def get_prediction_store_policy(
|
||||
self, prediction_store_policy: str, metadata: dict[str, Any]
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Parse and validate prediction store policy configuration.
|
||||
|
||||
This method parses prediction store policy strings in the format 'type:value'
|
||||
and validates them against allowed policy types and values. It provides
|
||||
sensible defaults for invalid configurations and logs policy validation
|
||||
failures for operational monitoring.
|
||||
|
||||
Supported Policy Types:
|
||||
- 'lts': Latest timestamp - sorts data by timestamp descending
|
||||
- 'erl': Earliest timestamp - sorts data by timestamp ascending
|
||||
|
||||
Args:
|
||||
prediction_store_policy (str): Policy string in format 'type:value'
|
||||
metadata (dict[str, Any]): Context metadata for logging and notifications
|
||||
|
||||
Returns:
|
||||
tuple[str, int]: (policy_type, policy_value)
|
||||
- policy_type (str): Validated policy type ('lts' or 'erl')
|
||||
- policy_value (int): Number of rows to retain
|
||||
"""
|
||||
policy_elements = prediction_store_policy.split(':')
|
||||
|
||||
if len(policy_elements) < 2:
|
||||
self.error(
|
||||
f'Invalid prediction store policy: {prediction_store_policy}, using default policy',
|
||||
metadata,
|
||||
)
|
||||
return 'lts', 1
|
||||
|
||||
policy_type = policy_elements[0]
|
||||
policy_value = policy_elements[1]
|
||||
|
||||
# If the policy_type is not lts or erl, we use the default policy
|
||||
# If the policty_value is not a number or 0, we use the default policy
|
||||
if (
|
||||
policy_type not in ['lts', 'erl']
|
||||
or not policy_value.isdigit()
|
||||
or int(policy_value) == 0
|
||||
):
|
||||
self.error(
|
||||
f'Invalid prediction store policy: {prediction_store_policy}, using default policy',
|
||||
metadata,
|
||||
)
|
||||
return 'lts', 1
|
||||
|
||||
return policy_type, int(policy_value)
|
||||
|
||||
@activity.defn(name='format_prediction')
|
||||
async def format_prediction(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
||||
"""
|
||||
Format prediction data according to configured storage policies.
|
||||
|
||||
This method formats prediction data for storage and export operations.
|
||||
It applies timestamp-based sorting policies, adds metadata fields,
|
||||
and ensures data consistency before persistence. The method supports
|
||||
multiple storage policies for flexible data retention strategies.
|
||||
|
||||
Storage Policies:
|
||||
- 'lts:N': Latest timestamp - retains N most recent predictions
|
||||
- 'erl:N': Earliest timestamp - retains N oldest predictions
|
||||
|
||||
Args:
|
||||
input_data (dict): Input data containing:
|
||||
- data (dict[str, Any]): Raw prediction data to format
|
||||
- timestamp (str): Default timestamp if data lacks timestamp column
|
||||
- model_id (str): Unique identifier for the ML model
|
||||
- prediction_confidence (float): Confidence score for the prediction
|
||||
- prediction_store_policy (str): Storage policy in format 'type:value'
|
||||
|
||||
Returns:
|
||||
dict: Formatted prediction data ready for storage and export
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
prediction_store_policy = input_data['prediction_store_policy']
|
||||
self.info('Formatting prediction...', metadata)
|
||||
|
||||
data = DataFrame(input_data['data'])
|
||||
|
||||
# Create timestamp column from index and reset index
|
||||
data['timestamp'] = data.index
|
||||
data = data.reset_index(drop=True)
|
||||
|
||||
self.debug(f'Prediction store policy: {prediction_store_policy}', metadata)
|
||||
self.debug(f'Prediction data: {data.head(5).to_string()}', metadata)
|
||||
|
||||
policy_type, policy_value = self.get_prediction_store_policy(
|
||||
prediction_store_policy, metadata
|
||||
)
|
||||
|
||||
# If data has no timestamp, we use the default timestamp and not sort the data
|
||||
self.info(
|
||||
f'Sorting data by timestamp and applying policy: {policy_type}:{policy_value}', metadata
|
||||
)
|
||||
|
||||
# If policy_type is lts, we need to sort the data by timestamp descending and take the first policy_value rows
|
||||
if policy_type == 'lts':
|
||||
self.debug('Sorting data by timestamp descending', metadata)
|
||||
data = data.sort_values(by='timestamp', ascending=False)
|
||||
# If policy_type is erl, we need to sort the data by timestamp ascending and take the first policy_value rows
|
||||
elif policy_type == 'erl':
|
||||
self.debug('Sorting data by timestamp ascending', metadata)
|
||||
data = data.sort_values(by='timestamp', ascending=True)
|
||||
else:
|
||||
self.error(f'Invalid policy type: {policy_type}, using default policy', metadata)
|
||||
raise ValueError(f'Invalid policy type: {policy_type}')
|
||||
|
||||
data = data.head(int(policy_value))
|
||||
|
||||
data['model_id'] = input_data['model_id']
|
||||
data['prediction_confidence'] = input_data['prediction_confidence']
|
||||
data['prediction_status'] = 'Good'
|
||||
data['comments'] = ''
|
||||
data = data.sort_values(by='timestamp', ascending=False)
|
||||
data = data.reset_index(drop=True)
|
||||
|
||||
self.info(f'Prediction formatted: {len(data)} rows', metadata)
|
||||
self.debug(f'Prediction data: {data.head(5).to_string()}', metadata)
|
||||
|
||||
return data.to_dict()
|
||||
|
||||
@activity.defn(name='format_default_prediction')
|
||||
async def format_default_prediction(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
||||
"""
|
||||
Create and format default prediction data for error conditions.
|
||||
|
||||
This method generates default prediction data when the main prediction
|
||||
pipeline encounters errors or quality issues. It creates a standardized
|
||||
data structure with zero values for predictions and useful metadata
|
||||
for operational monitoring and debugging.
|
||||
|
||||
The default prediction serves as a fallback mechanism to:
|
||||
1. Maintain data pipeline continuity during failures
|
||||
2. Provide operational visibility into prediction quality issues
|
||||
3. Enable downstream systems to handle error conditions gracefully
|
||||
4. Support debugging and troubleshooting efforts
|
||||
|
||||
Args:
|
||||
input_data (dict): Input data containing:
|
||||
- timestamp (str): Timestamp for the default prediction
|
||||
- model_id (str): Unique identifier for the ML model
|
||||
- prediction_confidence (float): Confidence score (typically low for errors)
|
||||
- comment (str): Error description or operational comment
|
||||
|
||||
Returns:
|
||||
dict: Formatted default prediction data with error indicators
|
||||
"""
|
||||
|
||||
metadata = input_data['metadata']
|
||||
self.debug('Formatting default prediction...', metadata)
|
||||
|
||||
data = DataFrame(
|
||||
{
|
||||
'prediction': [0],
|
||||
'response_time': [0],
|
||||
'timestamp': [input_data['timestamp']],
|
||||
'model_id': [input_data['model_id']],
|
||||
'prediction_confidence': [input_data['prediction_confidence']],
|
||||
'prediction_status': ['Bad'],
|
||||
'comments': [input_data['comment']],
|
||||
}
|
||||
)
|
||||
|
||||
self.info(f'Default prediction formatted: {data.size} rows', metadata)
|
||||
return data.to_dict()
|
||||
|
||||
@activity.defn(name='get_last_timestamp')
|
||||
async def get_last_timestamp(self, input_data: dict[str, Any]) -> str:
|
||||
"""
|
||||
Extract the most recent timestamp from prediction data.
|
||||
|
||||
This method analyzes prediction data to find the latest timestamp,
|
||||
enabling incremental processing and data continuity tracking.
|
||||
It handles empty datasets gracefully by returning the current time
|
||||
as a fallback timestamp.
|
||||
|
||||
The method is essential for:
|
||||
1. Incremental data processing workflows
|
||||
2. Data continuity validation
|
||||
3. Timestamp-based data loading optimization
|
||||
4. Workflow execution tracking
|
||||
|
||||
Args:
|
||||
input_data (dict): Input data containing:
|
||||
- data (dict[str, Any]): Prediction data to analyze
|
||||
|
||||
Returns:
|
||||
str: Formatted timestamp string in UTC with timezone
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
|
||||
self.info('Getting last timestamp...', metadata)
|
||||
|
||||
data = DataFrame(input_data['data'])
|
||||
|
||||
self.debug(f'Input data: {data.head(5).to_string()}', metadata)
|
||||
|
||||
if data.empty:
|
||||
return now().strftime(DATETIME_FORMAT_WITH_TZ)
|
||||
|
||||
max_timestamp = max(data['timestamp'].values.tolist())
|
||||
|
||||
self.info(f'Last timestamp: {max_timestamp}', metadata)
|
||||
|
||||
return max_timestamp
|
||||
|
||||
@activity.defn(name='write_metrics')
|
||||
async def write_metrics(self, input_data: dict[str, Any]):
|
||||
"""
|
||||
Write prediction performance metrics to Prometheus monitoring system.
|
||||
|
||||
This method records comprehensive metrics for prediction operations,
|
||||
enabling operational monitoring, performance analysis, and alerting.
|
||||
It tracks prediction counts, confidence levels, and response times
|
||||
for each model and pipeline combination.
|
||||
|
||||
Metrics Recorded:
|
||||
1. Prediction Count: Incremental counter for successful predictions
|
||||
2. Confidence Monitor: Current confidence level for predictions
|
||||
3. Response Time Monitor: Histogram of prediction response times
|
||||
|
||||
Args:
|
||||
input_data (dict): Input data containing:
|
||||
- metadata (dict[str, Any]): Workflow execution metadata
|
||||
- prediction (dict[str, Any]): Prediction data with metrics
|
||||
|
||||
Raises:
|
||||
Exception: If metrics writing fails or configuration is invalid
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
prediction = DataFrame(input_data['prediction'])
|
||||
prediction_confidence = prediction['prediction_confidence'].values[0]
|
||||
response_time = prediction['response_time'].values[0]
|
||||
|
||||
self.info(f'Writing metrics for model {metadata["model_name"]}', metadata)
|
||||
|
||||
metrics.PREDICTIONS_WRITTEN_COUNT.labels(
|
||||
pod_id=self.pod_id,
|
||||
model_name=metadata['model_name'],
|
||||
pipeline_name=metadata['workflow_name'],
|
||||
).inc()
|
||||
|
||||
metrics.PREDICTION_CONFIDENCE_MONITOR.labels(
|
||||
pod_id=self.pod_id,
|
||||
model_name=metadata['model_name'],
|
||||
pipeline_name=metadata['workflow_name'],
|
||||
).set(prediction_confidence)
|
||||
|
||||
metrics.PREDICTION_RESPONSE_TIME_MONITOR.labels(
|
||||
pod_id=self.pod_id,
|
||||
model_name=metadata['model_name'],
|
||||
pipeline_name=metadata['workflow_name'],
|
||||
).observe(response_time)
|
||||
|
||||
self.info(f'Metrics written for model {metadata["model_name"]}', metadata)
|
||||
@@ -4,14 +4,10 @@ with workflow.unsafe.imports_passed_through():
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from pandas import DataFrame, to_datetime
|
||||
from sientia_do.formatters import create_sample_dict
|
||||
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 sientia_do.temporal.constants import DATETIME_FORMAT, DATETIME_FORMAT_WITH_TZ
|
||||
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
from model_manager.utils.repository.model_repository import MLFlowRepository
|
||||
@@ -19,14 +15,14 @@ with workflow.unsafe.imports_passed_through():
|
||||
|
||||
class MLFlow(BaseActivity):
|
||||
"""
|
||||
MLFlow integration activities for model inference operations.
|
||||
MLFlow integration activities for model training operations.
|
||||
|
||||
This class provides activities for interacting with MLFlow models, including
|
||||
data transformation and prediction operations. It handles authentication,
|
||||
data preprocessing, and model management with configurable retention policies.
|
||||
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 comprehensive error handling and logging for all
|
||||
MLFlow operations, ensuring reliable model inference in production environments.
|
||||
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
|
||||
@@ -69,283 +65,6 @@ class MLFlow(BaseActivity):
|
||||
f'{mlflow_host}:{mlflow_port}', mlflow_username, mlflow_password, logger
|
||||
)
|
||||
|
||||
@activity.defn(name='request_transform')
|
||||
async def request_transform(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Transform input data using MLFlow models.
|
||||
|
||||
This activity processes input data through MLFlow model transformation,
|
||||
including data preprocessing, format conversion, and validation. It handles
|
||||
data deduplication, pivoting, and cleanup to ensure optimal model performance.
|
||||
|
||||
The transformation process includes:
|
||||
1. Data deduplication based on variable and timestamp
|
||||
2. Data pivoting for model input format
|
||||
3. Null value handling and cleanup
|
||||
4. MLFlow model transformation request
|
||||
5. Response validation and logging
|
||||
|
||||
Args:
|
||||
input_data: Configuration and data for transformation
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- data (dict): Input data for transformation
|
||||
- model_name (str): Name of the MLFlow model to use
|
||||
- model_retention (int): Model retention period in minutes
|
||||
|
||||
Returns:
|
||||
dict: Transformed data from MLFlow model
|
||||
|
||||
Raises:
|
||||
Exception: If transformation fails or MLFlow model is unavailable
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
self.info('Transforming data...', metadata)
|
||||
data = DataFrame(input_data['data'])
|
||||
model_name = input_data['model_name']
|
||||
model_config = input_data.get('model_config', {})
|
||||
|
||||
self.debug('Raw input data:', metadata)
|
||||
self.debug(data.head(5).to_string(), metadata)
|
||||
|
||||
# Sort by created_at in descending order and keep first occurrence of each variable/timestamp pair
|
||||
data = data.sort_values('created_at', ascending=False).drop_duplicates(
|
||||
subset=['variable', 'timestamp'], keep='first'
|
||||
)
|
||||
|
||||
# Pivot data for model input format
|
||||
data = data.pivot(index='timestamp', columns='variable', values='value')
|
||||
data.fillna(np.nan, inplace=True)
|
||||
# data.reset_index(inplace=True)
|
||||
data.columns.name = None
|
||||
|
||||
self.debug('Processed input data:', metadata)
|
||||
self.debug(data.head(5).to_string(), metadata)
|
||||
|
||||
# Request transformation from MLFlow model
|
||||
response_data = self.model_monitoring_repository.transform(
|
||||
model_name, data, model_config, metadata
|
||||
)
|
||||
|
||||
self.debug(
|
||||
f'Transform raw response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
self.debug(
|
||||
f'Transform response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
self.info('Data transformed successfully', metadata)
|
||||
|
||||
return response_data
|
||||
|
||||
@activity.defn(name='request_predict')
|
||||
async def request_predict(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Execute predictions using MLFlow models.
|
||||
|
||||
This activity performs ML model inference using MLFlow models with the
|
||||
transformed data. It handles data format conversion, null value processing,
|
||||
and model prediction requests with comprehensive error handling.
|
||||
|
||||
The prediction process includes:
|
||||
1. Data format validation and cleanup
|
||||
2. Null value handling for model compatibility
|
||||
3. MLFlow model prediction request
|
||||
4. Response validation and logging
|
||||
5. Performance monitoring and metrics
|
||||
|
||||
Args:
|
||||
input_data: Configuration and data for prediction
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- data (dict): Transformed data for prediction
|
||||
- model_name (str): Name of the MLFlow model to use
|
||||
- model_retention (int): Model retention period in minutes
|
||||
|
||||
Returns:
|
||||
dict: Prediction results from MLFlow model
|
||||
|
||||
Raises:
|
||||
Exception: If prediction fails or MLFlow model is unavailable
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
self.info('Predicting data...', metadata)
|
||||
data = DataFrame(input_data['data'])
|
||||
model_name = input_data['model_name']
|
||||
model_config = input_data.get('model_config', {})
|
||||
|
||||
self.debug(f'Input data for: \n {data.head(5).to_string()}', metadata)
|
||||
|
||||
# Convert numpy.nan to None for model compatibility
|
||||
data.replace(np.nan, None, inplace=True)
|
||||
|
||||
data['timestamp'] = data.index
|
||||
data['timestamp'] = to_datetime(
|
||||
data['timestamp'], format=DATETIME_FORMAT_WITH_TZ
|
||||
).dt.strftime(DATETIME_FORMAT)
|
||||
|
||||
# Request prediction from MLFlow model
|
||||
response_data = self.model_monitoring_repository.predict(
|
||||
model_name, data, model_config, metadata
|
||||
)
|
||||
|
||||
self.debug(
|
||||
f'Prediction response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
self.info('Data predicted successfully', metadata)
|
||||
|
||||
return response_data
|
||||
|
||||
@activity.defn(name='retrain_model')
|
||||
async def retrain_model(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Retrain MLFlow models with updated training data.
|
||||
|
||||
This activity orchestrates the complete model retraining process,
|
||||
including data preparation, model retraining execution, and result
|
||||
validation. It handles data preprocessing, column cleanup, and
|
||||
comprehensive error handling for production model management.
|
||||
|
||||
The retraining process includes:
|
||||
1. Data timestamp extraction and validation
|
||||
2. Column cleanup and data preparation
|
||||
3. Data pivoting for model input format
|
||||
4. MLFlow model retraining execution
|
||||
5. Result validation and error handling
|
||||
|
||||
Args:
|
||||
input_data (dict): Input data containing:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- data (dict[str, Any]): Training data for model retraining
|
||||
- model_name (str): Name of the MLFlow model to retrain
|
||||
|
||||
Returns:
|
||||
dict: Retraining results containing:
|
||||
- status (str): Retraining operation status
|
||||
- timestamp (str): Timestamp of the retraining operation
|
||||
- experiment (str): MLFlow experiment identifier
|
||||
|
||||
Raises:
|
||||
Exception: If retraining fails or encounters critical errors
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
data = DataFrame(input_data['data'])
|
||||
model_name = input_data['model_name']
|
||||
|
||||
self.info(f'Retraining model {model_name}...', metadata)
|
||||
|
||||
timestamp = data['timestamp'].max()
|
||||
self.debug(f'Timestamp: {timestamp}', metadata)
|
||||
|
||||
data.drop(columns=['model_id'], inplace=True, errors='ignore')
|
||||
data.drop(columns=['created_at'], inplace=True, errors='ignore')
|
||||
|
||||
data = data.pivot(index='timestamp', columns='variable', values='value')
|
||||
data.sort_index(inplace=True)
|
||||
data.reset_index(inplace=True)
|
||||
|
||||
data = data.dropna()
|
||||
data.columns.name = None
|
||||
|
||||
try:
|
||||
retrain_output, experiment = self.model_monitoring_repository.retrain_model(
|
||||
data=data, model_name=model_name
|
||||
)
|
||||
|
||||
return {'status': retrain_output, 'timestamp': timestamp, 'experiment': experiment}
|
||||
except Exception as e:
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id='RETRAIN_MODEL_ERROR',
|
||||
message=f'Error retraining model {model_name}: {e}',
|
||||
block='retrain_model',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
self.error(trace, metadata=metadata)
|
||||
raise e
|
||||
|
||||
@activity.defn(name='update_production_model')
|
||||
async def update_production_model(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
||||
"""
|
||||
Update production model with newly trained model version.
|
||||
|
||||
This activity manages the critical process of updating production
|
||||
models with newly trained versions. It handles model deployment,
|
||||
status tracking, and comprehensive reporting for operational
|
||||
visibility and audit trails.
|
||||
|
||||
The update process includes:
|
||||
1. Production model update execution
|
||||
2. Status and metadata tracking
|
||||
3. Comprehensive reporting and logging
|
||||
4. Error handling and notification
|
||||
5. Audit trail maintenance
|
||||
|
||||
Args:
|
||||
input_data (dict): Input data containing:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- model_name (str): Name of the MLFlow model to update
|
||||
- experiment (str): MLFlow experiment identifier
|
||||
- model_id (str): Unique identifier for the model version
|
||||
- timestamp (str): Timestamp of the update operation
|
||||
- status (str): Current status of the model update
|
||||
|
||||
Returns:
|
||||
dict[Any, Any]: Comprehensive update report containing:
|
||||
- model_id (str): Model version identifier
|
||||
- model_name (str): Name of the updated model
|
||||
- timestamp (str): Update operation timestamp
|
||||
- status (str): Update operation status
|
||||
- Additional MLFlow response metadata
|
||||
|
||||
Raises:
|
||||
Exception: If production model update fails
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
model_name = input_data['model_name']
|
||||
model_id = input_data['model_id']
|
||||
experiment = input_data['experiment']
|
||||
timestamp = input_data['timestamp']
|
||||
status = input_data['status']
|
||||
|
||||
self.info(
|
||||
f'Updating production model {model_name} from experiment {experiment}...', metadata
|
||||
)
|
||||
|
||||
try:
|
||||
response = self.model_monitoring_repository.update_production_model(
|
||||
experiment=experiment, model_name=model_name
|
||||
)
|
||||
|
||||
report = DataFrame([response])
|
||||
report['model_id'] = model_id
|
||||
report['model_name'] = model_name
|
||||
report['timestamp'] = timestamp
|
||||
report['status'] = status
|
||||
|
||||
self.info(f'Production model {model_name} updated successfully', metadata)
|
||||
return report.to_dict() # type: ignore[no-any-return]
|
||||
|
||||
except Exception as e:
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
notification_id='UPDATE_PRODUCTION_MODEL_ERROR',
|
||||
message=f'Error updating production model {model_name}: {e}',
|
||||
block='update_production_model',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=trace,
|
||||
)
|
||||
self.error(trace, metadata=metadata)
|
||||
raise e
|
||||
|
||||
@activity.defn(name='save_model')
|
||||
async def save_model(self, input_data: dict[str, Any]) -> TrainModelResult:
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@ Model Manager Metrics Module
|
||||
|
||||
This module defines all Prometheus metrics used by the Sientia DataOps Model Manager system
|
||||
for monitoring and observability. The metrics provide insights into system performance,
|
||||
prediction quality, and operational health.
|
||||
training operations, and operational health.
|
||||
|
||||
The metrics are designed to be scraped by Prometheus and can be visualized in
|
||||
Grafana or other monitoring dashboards to provide real-time visibility into
|
||||
@@ -11,18 +11,12 @@ the system's operation.
|
||||
|
||||
Key Metric Categories:
|
||||
- Application Health: Overall system status and availability
|
||||
- Prediction Operations: Count and performance of prediction operations
|
||||
- Data Quality: Confidence levels and validation results
|
||||
- Export Operations: Database export performance
|
||||
- Response Times: Performance monitoring for various operations
|
||||
|
||||
Metric Labels:
|
||||
- pod_id: Kubernetes pod identifier for multi-instance deployments
|
||||
- model_name: Name of the ML model being used
|
||||
- pipeline_name: Name of the prediction pipeline
|
||||
"""
|
||||
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
from prometheus_client import Gauge
|
||||
|
||||
# Application health metric
|
||||
APP_UP = Gauge(
|
||||
@@ -30,28 +24,3 @@ APP_UP = Gauge(
|
||||
'Indicates if the application is running (1) or shutting down (0)',
|
||||
['pod_id'],
|
||||
)
|
||||
|
||||
# Core labels used across multiple metrics
|
||||
CORE_LABELS = ['pod_id', 'model_name', 'pipeline_name']
|
||||
|
||||
# Prediction operation metrics
|
||||
PREDICTIONS_WRITTEN_COUNT = Counter(
|
||||
'model_manager_predictions_written_count',
|
||||
'Number of predictions written to the database table predictions',
|
||||
CORE_LABELS,
|
||||
)
|
||||
|
||||
# Prediction quality metrics
|
||||
PREDICTION_CONFIDENCE_MONITOR = Gauge(
|
||||
'model_manager_prediction_confidence_monitor',
|
||||
'Current confidence of each prediction',
|
||||
CORE_LABELS,
|
||||
)
|
||||
|
||||
# Performance monitoring metrics
|
||||
PREDICTION_RESPONSE_TIME_MONITOR = Histogram(
|
||||
'model_manager_prediction_response_time_monitor',
|
||||
'Current response time of each prediction',
|
||||
CORE_LABELS,
|
||||
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||
)
|
||||
|
||||
3
model_manager/sientia/exceptions.py
Normal file
3
model_manager/sientia/exceptions.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from mlflow.exceptions import MlflowException
|
||||
|
||||
SientiaMlException = MlflowException
|
||||
28
model_manager/sientia/metrics.py
Normal file
28
model_manager/sientia/metrics.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
|
||||
|
||||
|
||||
def mse(real_data: pd.Series, predictions: pd.Series) -> float:
|
||||
"""
|
||||
Calculates the mean squared error between the real data and the predictions.
|
||||
"""
|
||||
return round(
|
||||
mean_squared_error(real_data.astype(np.float64), predictions.astype(np.float64)), 2
|
||||
)
|
||||
|
||||
|
||||
def mae(real_data: pd.Series, predictions: pd.Series) -> float:
|
||||
"""
|
||||
Calculates the mean absolute error between the real data and the predictions.
|
||||
"""
|
||||
return round(
|
||||
mean_absolute_error(real_data.astype(np.float64), predictions.astype(np.float64)), 2
|
||||
)
|
||||
|
||||
|
||||
def r2(real_data: pd.Series, predictions: pd.Series) -> float:
|
||||
"""
|
||||
Calculates the R2 score between the real data and the predictions.
|
||||
"""
|
||||
return round(r2_score(real_data.astype(np.float64), predictions.astype(np.float64)), 2)
|
||||
204
model_manager/sientia/model_serving.py
Normal file
204
model_manager/sientia/model_serving.py
Normal file
@@ -0,0 +1,204 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import mlflow
|
||||
import mlflow.sklearn
|
||||
import pandas as pd
|
||||
|
||||
from model_manager.sientia.exceptions import SientiaMlException
|
||||
|
||||
|
||||
class ModelServing:
|
||||
"""
|
||||
MLflow model serving wrapper.
|
||||
|
||||
Thread-safety note: This class modifies global state (MLflow tracking URI and
|
||||
environment variables) during initialization. In multi-threaded environments,
|
||||
ensure that:
|
||||
1. Instances are created with the same tracking_uri/credentials, OR
|
||||
2. Instance creation is synchronized (e.g., using a lock), OR
|
||||
3. Create a single instance and share it across threads
|
||||
|
||||
The MLflow operations themselves (log_param, log_metric, etc.) are thread-safe
|
||||
when operating on different runs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tracking_uri: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
logger: Any | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize ModelServing client.
|
||||
|
||||
WARNING: This modifies global state (MLflow config and environment variables).
|
||||
Not thread-safe during initialization if different credentials are used.
|
||||
|
||||
Args:
|
||||
tracking_uri: MLflow tracking server URI
|
||||
username: Optional MLflow username
|
||||
password: Optional MLflow password
|
||||
logger: Optional logger (currently unused)
|
||||
"""
|
||||
# Set tracking URI (modifies global MLflow state)
|
||||
mlflow.set_tracking_uri(tracking_uri)
|
||||
|
||||
# Set credentials in environment variables (global state)
|
||||
if username is not None:
|
||||
os.environ['MLFLOW_TRACKING_USERNAME'] = username
|
||||
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
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
List runs for a specified MLflow experiment.
|
||||
|
||||
Args:
|
||||
experiment_names (list[str]): List with experiment_names to retrieve runs from.
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: A DataFrame containing run information.
|
||||
|
||||
Raise:
|
||||
SientiaMlException if unable to search runs
|
||||
"""
|
||||
try:
|
||||
runs = mlflow.search_runs(experiment_names=experiment_names, order_by=order_by)
|
||||
except SientiaMlException as e:
|
||||
logging.error(e)
|
||||
raise SientiaMlException from e
|
||||
return runs
|
||||
|
||||
def set_experiment(self, experiment_identifier: str) -> None:
|
||||
"""
|
||||
Set the given experiment as the active experiment.
|
||||
|
||||
Args:
|
||||
experiment_identifier (str): name or id of the experiment to be setted
|
||||
"""
|
||||
mlflow.set_experiment(experiment_identifier)
|
||||
|
||||
def log_model(self, sk_model: Any, artifact_path: Any, **kwargs) -> None:
|
||||
"""
|
||||
Log a sklearn model.
|
||||
|
||||
Args:
|
||||
sk_model: scikit-learn model to be saved.
|
||||
artifact_path: Run-relative artifact path.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Security Warning:
|
||||
The GitHub token is hardcoded. Consider moving to environment variable
|
||||
or using a secure secret management solution (e.g., K8s secrets).
|
||||
"""
|
||||
mlflow.sklearn.log_model(
|
||||
sk_model,
|
||||
artifact_path,
|
||||
extra_pip_requirements=[os.getenv('EXTRA_PIP_REQUIREMENTS')],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def log_param(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Log a param in the active run.
|
||||
|
||||
Args:
|
||||
key (str): Param name
|
||||
value (any): Param value
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
mlflow.log_param(key, value)
|
||||
|
||||
def log_metric(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Log a metric in the active run.
|
||||
|
||||
Args:
|
||||
key (str): Metric name
|
||||
value (any): Metric value
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
mlflow.log_metric(key, value)
|
||||
|
||||
def log_artifact(
|
||||
self, local_path: str, artifact_path: str | None = None, run_id: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Log an artifact.
|
||||
|
||||
Args:
|
||||
local_path: Local path of the artifact to log.
|
||||
artifact_path: If provided, the directory in artifact_uri to write to.
|
||||
run_id: optional id of current run
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
mlflow.log_artifact(local_path=local_path, artifact_path=artifact_path, run_id=run_id)
|
||||
|
||||
@contextmanager
|
||||
def save_experiment(
|
||||
self,
|
||||
run_id: str | None = None,
|
||||
experiment_id: str | None = None,
|
||||
run_name: str | None = None,
|
||||
nested: bool = False,
|
||||
tags: dict[str, Any] | None = None,
|
||||
description: str | None = None,
|
||||
log_system_metrics: bool | None = None,
|
||||
) -> Generator[mlflow.ActiveRun, None, None]:
|
||||
"""
|
||||
Context manager to save an experiment, ensuring the run is properly closed.
|
||||
|
||||
This prevents memory leaks by guaranteeing that MLflow runs are always ended,
|
||||
even if an exception occurs. Thread-safe when used with proper MLflow configuration.
|
||||
|
||||
Args:
|
||||
run_id: If specified, get the run with the specified UUID and log parameters and metrics under that run.
|
||||
experiment_id: ID of the experiment under which to create the current run (applicable only when run_id is not specified).
|
||||
run_name: Name of new run. Used only when run_id is unspecified.
|
||||
nested: Controls whether run is nested in parent run. True creates a nested run.
|
||||
tags: An optional dictionary of string keys and values to set as tags on the run. If a run is being resumed, these tags are set on the resumed run. If a new run is being created, these tags are set on the new run.
|
||||
description: An optional string that populates the description box of the run.
|
||||
log_system_metrics: If True, system metrics will be logged. If None, we will check environment variable
|
||||
|
||||
Yields:
|
||||
ActiveRun: object that acts as a context manager wrapping the run's state.
|
||||
|
||||
Example:
|
||||
with model_serving.save_experiment(run_name="my_run") as run:
|
||||
model_serving.log_param("param1", value1)
|
||||
model_serving.log_metric("metric1", value2)
|
||||
# Run is automatically closed here, even if an exception occurs
|
||||
"""
|
||||
run = mlflow.start_run(
|
||||
run_id=run_id,
|
||||
experiment_id=experiment_id,
|
||||
run_name=run_name,
|
||||
nested=nested,
|
||||
tags=tags,
|
||||
description=description,
|
||||
log_system_metrics=log_system_metrics,
|
||||
)
|
||||
try:
|
||||
yield run
|
||||
finally:
|
||||
# Ensure run is always ended, preventing resource leaks
|
||||
mlflow.end_run()
|
||||
554
model_manager/sientia/models.py
Normal file
554
model_manager/sientia/models.py
Normal file
@@ -0,0 +1,554 @@
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sientia_do.operations.df_preprocessor import create_features, limit_dataset, treat_nan
|
||||
from sientia_do.timeseries.analyzer import TimeSeriesDiscontinuityAnalyzer
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
DISCONTINUITY_TREATMENT = 'Discontinuity Treatment'
|
||||
LAG_SELECTION = 'Lag Selection'
|
||||
STATIC_WINDOW_REMOVAL = 'Static Window Removal'
|
||||
DEFINE_VARIABLES_LIMITS = 'Define Variables Limits'
|
||||
NORMALIZATION = 'Normalization'
|
||||
|
||||
|
||||
class LinearRegressionModel(BaseEstimator, TransformerMixin):
|
||||
"""
|
||||
Linear Regression Model for Time Series Analysis.
|
||||
|
||||
Thread-safety: This class is NOT thread-safe during fit() operations.
|
||||
Do not call fit() on the same instance from multiple threads simultaneously.
|
||||
After fitting, predict() is thread-safe for read-only operations.
|
||||
|
||||
For multi-threaded environments:
|
||||
- Fit the model in a single thread
|
||||
- Share the fitted instance across threads for prediction only
|
||||
- Or create separate instances per thread
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_variable: str = '',
|
||||
variable_columns: list[str] | None = None,
|
||||
model_params: dict[str, Any] | None = None,
|
||||
clipping: dict[str, float] | None = None,
|
||||
weights: dict[str, float] | None = None,
|
||||
):
|
||||
"""
|
||||
Linear Regression Model for Time Series Analysis
|
||||
|
||||
Args:
|
||||
target_variable (str): The target variable name
|
||||
variable_columns (list): The input columns names in a list
|
||||
model_params (dict): The parameters used for training the model \\
|
||||
clipping (dict): The lower and upper limits for the target variable to be clipped \\
|
||||
*Format: {'min': min_value, 'max': max_value}*
|
||||
weights (dict): The weights for the Linear Regression model \\
|
||||
*Format: {'variable_name': weight}*
|
||||
|
||||
Returns:
|
||||
LinearRegressionModel: The prediction model object
|
||||
"""
|
||||
self.target_variable: str = target_variable
|
||||
self.variable_columns: list[str] | None = variable_columns
|
||||
self.model_params: dict[str, Any] | None = model_params
|
||||
self.clipping: dict[str, float] | None = clipping
|
||||
self.regr = LinearRegression()
|
||||
self.q1_target: float | None = None
|
||||
self.q3_target: float | None = None
|
||||
self.weights: dict[str, float] | None = weights
|
||||
|
||||
def fit(self, input_data: pd.DataFrame) -> 'LinearRegressionModel':
|
||||
"""
|
||||
Function to fit the model
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The data used to fit the Linear Regression model
|
||||
|
||||
Returns:
|
||||
LinearRegressionModel: The prediction model object
|
||||
"""
|
||||
assert self.variable_columns is not None, 'variable_columns must be set before fitting'
|
||||
X_train = input_data[self.variable_columns]
|
||||
y_train = input_data[self.target_variable]
|
||||
|
||||
self.q1_target = y_train.quantile(0.25)
|
||||
self.q3_target = y_train.quantile(0.75)
|
||||
|
||||
# Fit the model
|
||||
self.regr.fit(X_train, y_train)
|
||||
|
||||
# Get the weights
|
||||
round_coef = np.round(self.regr.coef_, 3)
|
||||
round_intercept = np.round(self.regr.intercept_, 3)
|
||||
|
||||
# Save the weights
|
||||
weights = dict(zip(self.variable_columns, [float(c) for c in round_coef], strict=True))
|
||||
weights = dict(sorted(weights.items(), key=lambda item: abs(item[1]), reverse=True))
|
||||
weights = {'Bias': float(round_intercept), **weights}
|
||||
self.weights = weights
|
||||
|
||||
return self
|
||||
|
||||
def predict(self, input_data: pd.DataFrame) -> np.ndarray:
|
||||
"""
|
||||
Function to predict the target variable.
|
||||
If clipping is True, the predictions are clipped based on the target variable quartiles.
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The data used to predict the target variable
|
||||
|
||||
Returns:
|
||||
numpy.ndarray: The predicted target variable
|
||||
"""
|
||||
X_test = input_data[self.variable_columns]
|
||||
y_pred = self.regr.predict(X_test)
|
||||
|
||||
if self.clipping:
|
||||
for i in range(len(y_pred)):
|
||||
if y_pred[i] > self.clipping['max']:
|
||||
y_pred[i] = self.q3_target
|
||||
elif y_pred[i] < self.clipping['min']:
|
||||
y_pred[i] = self.q1_target
|
||||
|
||||
return y_pred
|
||||
|
||||
|
||||
class DataPreprocessor(BaseEstimator, TransformerMixin):
|
||||
"""
|
||||
Data Preprocessor for Time Series Analysis.
|
||||
|
||||
Thread-safety: This class is NOT thread-safe during fit() operations.
|
||||
Do not call fit() on the same instance from multiple threads simultaneously.
|
||||
After fitting, transform() is thread-safe for read-only operations IF the
|
||||
input DataFrames are not shared between threads.
|
||||
|
||||
For multi-threaded environments:
|
||||
- Fit the preprocessor in a single thread
|
||||
- Share the fitted instance across threads for transform() only
|
||||
- Ensure each thread passes its own DataFrame copy to transform()
|
||||
- Or create separate instances per thread
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
date_column: str = '',
|
||||
target_variable: str = '',
|
||||
input_columns: list[str] | None = None,
|
||||
nan_treatment: str | None = None,
|
||||
lag_train: dict[str, int] | None = None,
|
||||
lag_transform: dict[str, int] | None = None,
|
||||
static_threshold: int | None = None,
|
||||
low_lim: dict[str, float] | None = None,
|
||||
upp_lim: dict[str, float] | None = None,
|
||||
window: int | None = None,
|
||||
scaler_name: str | None = None,
|
||||
scaler_params: dict[str, Any] | None = None,
|
||||
ar_var: str | None = None,
|
||||
self_operations: list[str] | None = None,
|
||||
cross_operations: list[str] | None = None,
|
||||
created_lags: dict[str, int] | None = None,
|
||||
steps_order: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Data Preprocessor for Time Series Analysis
|
||||
|
||||
Args:
|
||||
date_column (str): The column name of the date in the dataset
|
||||
target_variable (str): The target variable name
|
||||
input_columns (list): The input columns names in a list
|
||||
nan_treatment (str): The treatment for missing values \\
|
||||
*Options: 'drop', 'fill linear'*
|
||||
lag_train (dict): The lags for each variable to be applyed during training \\
|
||||
*Format: {'variable_name': lag}*
|
||||
lag_transform (dict): The lags for each variable to be applyed during transformation \\
|
||||
*Format: {'variable_name': lag}*
|
||||
static_threshold (int): The number of repeated values to be considered as static
|
||||
low_lim (dict): The lower limits for each variable \\
|
||||
*Format: {'variable_name': limit}*
|
||||
upp_lim (dict): The upper limits for each variable \\
|
||||
*Format: {'variable_name': limit}*
|
||||
window (int): The window size for rolling window. **Not implemented yet**
|
||||
scaler_name (str): The scaler name. If no scaler is used, it is 'None' \\
|
||||
*Options: 'None', 'Standard Scaler'*
|
||||
scaler_params (dict): The parameters for the scaler object, if it is used \\
|
||||
*Format for Standard Scaler: {'variable_name': {'mean': mean, 'variance': variance}}*
|
||||
ar_var (str): The autoregressive variable name. If None, it is not created
|
||||
self_operations (list): The operations for feature creation using the same variable \\
|
||||
*Format: ['{variable_name}\\_{operation}\\_{scalar}']* \\
|
||||
*Operations: 'exp', 'pow', 'log', 'root'*
|
||||
cross_operations (list): The operations for feature creation using two variables \\
|
||||
*Format: ['{variable_name1}\\_{operation}\\_{variable_name2}']* \\
|
||||
*Operations: '\\*', '/'*
|
||||
created_lags (dict): Variables created by lagging existing ones \\
|
||||
*Format: {'original_variable_name': lag}*
|
||||
steps_order (list): The order of the steps to be executed in the pipeline \\
|
||||
*Options for list: 'Discontinuity Treatment',
|
||||
'Lag Selection',
|
||||
'Static Window Removal',
|
||||
'Define Variables Limits',
|
||||
'Normalization',
|
||||
'Feature Creation',
|
||||
'Lag Creation'*
|
||||
|
||||
Returns:
|
||||
DataPreprocessor: The data preprocessor object
|
||||
"""
|
||||
self.date_column = date_column
|
||||
self.target_variable = target_variable
|
||||
self.input_columns = input_columns
|
||||
self.nan_treatment = nan_treatment
|
||||
self.lag_train = lag_train if lag_train else {}
|
||||
self.lag_transform = lag_transform if lag_transform else {}
|
||||
self.ar_var = ar_var
|
||||
self.self_operations = self_operations
|
||||
self.cross_operations = cross_operations
|
||||
self.created_lags = created_lags
|
||||
self.static_threshold = static_threshold
|
||||
self.low_lim = low_lim
|
||||
self.upp_lim = upp_lim
|
||||
# self.window = window # Not implemented yet
|
||||
self.scaler_name = scaler_name
|
||||
self.scaler_params = scaler_params
|
||||
self.feature_names_order: list[str] = [] # Initialize to avoid AttributeError
|
||||
|
||||
if self.scaler_name == 'Standard Scaler':
|
||||
self.scaler = StandardScaler()
|
||||
elif self.scaler_name == 'None':
|
||||
self.scaler = None
|
||||
else:
|
||||
self.scaler = None
|
||||
|
||||
# Filter steps for preprocessor class
|
||||
possible_steps = [
|
||||
DISCONTINUITY_TREATMENT,
|
||||
LAG_SELECTION,
|
||||
STATIC_WINDOW_REMOVAL,
|
||||
DEFINE_VARIABLES_LIMITS,
|
||||
NORMALIZATION,
|
||||
'Feature Creation',
|
||||
'Lag Creation',
|
||||
]
|
||||
self.steps_order = steps_order or possible_steps
|
||||
for step in possible_steps:
|
||||
if step not in self.steps_order:
|
||||
self.steps_order.append(step)
|
||||
|
||||
def get_required_columns(self, existing_columns: list) -> list:
|
||||
"""
|
||||
Get the required columns to generate the input columns
|
||||
|
||||
Args:
|
||||
existing_columns (list): The existing columns in the data
|
||||
|
||||
Returns:
|
||||
list: The required columns
|
||||
"""
|
||||
required_columns: list[str] = []
|
||||
|
||||
# Columns for feature creation
|
||||
if self.self_operations is not None:
|
||||
for name in self.self_operations:
|
||||
var, operation, scalar = name.split('}_{')
|
||||
var = var.split('{')[1]
|
||||
operation = operation.split('}')[0]
|
||||
scalar = scalar.split('}')[0]
|
||||
required_columns.append(var)
|
||||
if self.cross_operations is not None:
|
||||
for name in self.cross_operations:
|
||||
var1, operation, var2 = name.split('}_{')
|
||||
var1 = var1.split('{')[1]
|
||||
operation = operation.split('}')[0]
|
||||
var2 = var2.split('}')[0]
|
||||
required_columns.append(var1)
|
||||
required_columns.append(var2)
|
||||
|
||||
# Columns for lag creation
|
||||
if self.created_lags is not None:
|
||||
for var in self.created_lags.keys():
|
||||
required_columns.append(var)
|
||||
|
||||
# Check if any column in required_columns is not in existing_columns
|
||||
required_columns = list(set(required_columns))
|
||||
_to_remove: list[str] = []
|
||||
for column in required_columns:
|
||||
# If column was already in self_operations list, remove it
|
||||
if (
|
||||
self.self_operations is not None
|
||||
and column not in existing_columns
|
||||
and column in self.self_operations
|
||||
):
|
||||
_to_remove.append(column)
|
||||
# If column was already in cross_operations list, remove it
|
||||
if (
|
||||
self.cross_operations is not None
|
||||
and column not in existing_columns
|
||||
and column in self.cross_operations
|
||||
):
|
||||
_to_remove.append(column)
|
||||
# If column was already in created_lags list, remove it
|
||||
if (
|
||||
self.created_lags is not None
|
||||
and column not in existing_columns
|
||||
and column in self.created_lags
|
||||
):
|
||||
_to_remove.append(column)
|
||||
for column in set(_to_remove):
|
||||
required_columns.remove(column)
|
||||
|
||||
return required_columns
|
||||
|
||||
def get_scaler(self) -> Any:
|
||||
"""
|
||||
Get the scaler object
|
||||
|
||||
Returns:
|
||||
Scaler: The scaler object
|
||||
"""
|
||||
return self.scaler
|
||||
|
||||
def treat_discontinuities(self, input_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Treat the discontinuities in the data
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
"""
|
||||
if self.nan_treatment:
|
||||
input_data = treat_nan(input_data, self.nan_treatment)
|
||||
return input_data
|
||||
|
||||
def lag_selection(self, input_data: pd.DataFrame, lag_dict: dict) -> pd.DataFrame:
|
||||
"""
|
||||
Select the lags for the variables
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
lag_dict (dict): The lags for each variable \\
|
||||
*Format: {'variable_name': lag}*
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
|
||||
Note:
|
||||
This method modifies input_data in-place. Ensure the caller passes
|
||||
a copy if the original DataFrame needs to be preserved.
|
||||
"""
|
||||
if lag_dict:
|
||||
for var, lag in lag_dict.items():
|
||||
if lag > 0:
|
||||
input_data[var] = input_data[var].shift(lag)
|
||||
# WARNING: Modifies DataFrame in-place
|
||||
input_data.dropna(inplace=True)
|
||||
return input_data
|
||||
|
||||
def treat_static_windows(self, input_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Treat the static windows in the data
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
"""
|
||||
if self.static_threshold:
|
||||
ts_analyzer = TimeSeriesDiscontinuityAnalyzer(input_data)
|
||||
ts_analyzer.infer_frequency()
|
||||
for col in input_data.columns:
|
||||
ts_analyzer.identify_static_windows(column=col, threshold=self.static_threshold)
|
||||
ts_analyzer.treat_static_windows(
|
||||
column=col, remove_window=True, threshold=self.static_threshold
|
||||
)
|
||||
ts_analyzer.update_total_discontinuities(col)
|
||||
input_data = ts_analyzer.get_treated_data()
|
||||
return input_data
|
||||
|
||||
def adjust_limits(self, input_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Adjust the limits for the variables
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
"""
|
||||
input_data, self.low_lim, self.upp_lim = limit_dataset(
|
||||
input_data, self.low_lim, self.upp_lim
|
||||
)
|
||||
return input_data
|
||||
|
||||
def create_features(self, input_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Create features in the data
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
"""
|
||||
input_data = create_features(input_data, self.self_operations, self.cross_operations)
|
||||
return input_data
|
||||
|
||||
def create_ar(self, input_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Create the autoregressive variable in the data
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
|
||||
Note:
|
||||
This method modifies input_data in-place.
|
||||
"""
|
||||
if self.ar_var:
|
||||
input_data[self.ar_var] = input_data[self.target_variable].shift(1)
|
||||
# WARNING: Modifies DataFrame in-place
|
||||
input_data.dropna(inplace=True)
|
||||
return input_data
|
||||
|
||||
def create_lags(self, input_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Create additional lags in the data
|
||||
|
||||
Args:
|
||||
input_data (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
|
||||
Note:
|
||||
This method modifies input_data in-place.
|
||||
"""
|
||||
if self.created_lags:
|
||||
for var, lag in self.created_lags.items():
|
||||
if lag > 0 and var in input_data.columns:
|
||||
new_col = f'{var}_lag{lag}'
|
||||
input_data[new_col] = input_data[var].shift(lag)
|
||||
# WARNING: Modifies DataFrame in-place
|
||||
input_data.dropna(inplace=True)
|
||||
return input_data
|
||||
|
||||
def fit(self, x: pd.DataFrame, y: None | pd.Series = None) -> 'DataPreprocessor':
|
||||
"""
|
||||
Function to preprocess the data and split it into training and testing sets
|
||||
|
||||
Args:
|
||||
x (pandas.DataFrame): The input data
|
||||
y (pandas.Series): The target variable
|
||||
|
||||
Returns:
|
||||
DataPreprocessor: The data preprocessor object
|
||||
"""
|
||||
if x is not None and y is not None:
|
||||
data_treat = pd.concat([x.copy(), y.copy()], axis=1)
|
||||
elif x is not None:
|
||||
data_treat = x.copy()
|
||||
else:
|
||||
raise ValueError('No data was provided')
|
||||
assert self.input_columns is not None, 'input_columns must be set'
|
||||
existing_columns = [col for col in data_treat.columns if col in self.input_columns]
|
||||
data_treat = data_treat[existing_columns + [self.target_variable]]
|
||||
|
||||
for step in self.steps_order:
|
||||
# Discontinuity Treatment
|
||||
if step == DISCONTINUITY_TREATMENT:
|
||||
data_treat = self.treat_discontinuities(data_treat)
|
||||
|
||||
# Lag for Model Training
|
||||
if step == LAG_SELECTION:
|
||||
data_treat = self.lag_selection(data_treat, self.lag_train)
|
||||
|
||||
# Static Window Treatment
|
||||
if step == STATIC_WINDOW_REMOVAL:
|
||||
data_treat = self.treat_static_windows(data_treat)
|
||||
|
||||
# Adjust limits
|
||||
if step == DEFINE_VARIABLES_LIMITS:
|
||||
data_treat = self.adjust_limits(data_treat)
|
||||
|
||||
# Normalization
|
||||
if step == NORMALIZATION and self.scaler:
|
||||
self.scaler = self.scaler.fit(data_treat[existing_columns])
|
||||
self.feature_names_order = list(data_treat[existing_columns].columns)
|
||||
data_treat[existing_columns] = self.scaler.transform(data_treat[existing_columns])
|
||||
|
||||
# Save scaler parameters
|
||||
assert self.scaler_params is not None, 'scaler_params must be initialized'
|
||||
for index, column in enumerate(list(existing_columns)):
|
||||
mean = self.scaler.mean_[index]
|
||||
variance = self.scaler.var_[index]
|
||||
self.scaler_params[column] = {
|
||||
'mean': round(mean, 3),
|
||||
'variance': round(variance, 3),
|
||||
}
|
||||
|
||||
return self
|
||||
|
||||
def transform(self, x: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Function to preprocess the data
|
||||
|
||||
Args:
|
||||
x (pandas.DataFrame): The input data
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The treated data
|
||||
"""
|
||||
if 'timestamp' in x.columns:
|
||||
data_treat = x.drop(columns='timestamp')
|
||||
else:
|
||||
data_treat = x.copy()
|
||||
assert self.input_columns is not None, 'input_columns must be set'
|
||||
existing_columns = [col for col in data_treat.columns if col in self.input_columns]
|
||||
required_columns = self.get_required_columns(existing_columns)
|
||||
all_cols = required_columns + existing_columns + [self.target_variable]
|
||||
all_cols = list(set(all_cols))
|
||||
data_treat = data_treat[all_cols]
|
||||
|
||||
for step in self.steps_order:
|
||||
# Discontinuity Treatment
|
||||
if step == DISCONTINUITY_TREATMENT:
|
||||
data_treat = self.treat_discontinuities(data_treat)
|
||||
|
||||
# Lag for Model Training
|
||||
if step == LAG_SELECTION:
|
||||
data_treat = self.lag_selection(data_treat, self.lag_transform)
|
||||
|
||||
# Static Window Treatment
|
||||
if step == STATIC_WINDOW_REMOVAL:
|
||||
data_treat = self.treat_static_windows(data_treat)
|
||||
|
||||
# Adjust limits
|
||||
if step == DEFINE_VARIABLES_LIMITS:
|
||||
data_treat = self.adjust_limits(data_treat)
|
||||
|
||||
# Normalization
|
||||
if step == NORMALIZATION and self.scaler:
|
||||
data_treat = data_treat[self.feature_names_order]
|
||||
data_treat[existing_columns] = self.scaler.transform(data_treat[existing_columns])
|
||||
|
||||
# Feature Creation
|
||||
if step == 'Feature Creation':
|
||||
data_treat = self.create_features(data_treat)
|
||||
|
||||
# Lag Creation
|
||||
if step == 'Lag Creation':
|
||||
# Autoregressive Variable
|
||||
if self.input_columns is not None and self.ar_var in self.input_columns:
|
||||
data_treat = self.create_ar(data_treat)
|
||||
|
||||
# Additonal Lags
|
||||
data_treat = self.create_lags(data_treat)
|
||||
|
||||
return data_treat
|
||||
259
model_manager/sientia/reports.py
Normal file
259
model_manager/sientia/reports.py
Normal file
@@ -0,0 +1,259 @@
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from evidently.metric_preset import DataDriftPreset
|
||||
from evidently.metrics import (
|
||||
ColumnSummaryMetric,
|
||||
ConflictTargetMetric,
|
||||
DatasetCorrelationsMetric,
|
||||
DatasetSummaryMetric,
|
||||
RegressionAbsPercentageErrorPlot,
|
||||
RegressionDummyMetric,
|
||||
RegressionErrorDistribution,
|
||||
RegressionErrorPlot,
|
||||
RegressionPerformanceMetrics,
|
||||
RegressionPredictedVsActualPlot,
|
||||
RegressionPredictedVsActualScatter,
|
||||
)
|
||||
from evidently.metrics.base_metric import generate_column_metrics
|
||||
from evidently.options import ColorOptions
|
||||
from evidently.report import Report
|
||||
|
||||
COLOR_DISCRETE_SEQUENCE = (
|
||||
'#ed0400',
|
||||
'#0a5f38',
|
||||
'#6c3461',
|
||||
'#71aa34',
|
||||
'#d8dcd6',
|
||||
'#6b8ba4',
|
||||
)
|
||||
|
||||
|
||||
def load_html_from_file(file_path):
|
||||
try:
|
||||
with open(file_path, encoding='utf-8') as file:
|
||||
return file.read()
|
||||
except FileNotFoundError:
|
||||
print(f'File not found: {file_path}')
|
||||
return None
|
||||
except OSError as e: # noqa: BLE001
|
||||
print(f'Error reading file: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def inject_content(main_html, section_id, content):
|
||||
soup = BeautifulSoup(main_html, 'html.parser')
|
||||
section = soup.find(id=section_id)
|
||||
if section:
|
||||
section.clear()
|
||||
section.append(BeautifulSoup(content, 'html.parser'))
|
||||
else:
|
||||
print(f"Section with id '{section_id}' not found in the main HTML template.")
|
||||
return str(soup)
|
||||
|
||||
|
||||
class Reports:
|
||||
"""
|
||||
Report generator using Evidently library.
|
||||
|
||||
Thread-safety: This class is NOT thread-safe. Multiple threads should not
|
||||
call add_*_section() methods on the same instance simultaneously as they
|
||||
modify shared state (self.metrics, self.sections, self.options).
|
||||
|
||||
For multi-threaded environments:
|
||||
- Create separate Reports instances per thread
|
||||
- Or synchronize access using locks
|
||||
- After generation, instances are safe for read-only operations
|
||||
|
||||
I/O Note: This class relies on Evidently's report.save_html() method
|
||||
for file operations. Ensure Evidently properly manages file handles.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, reference_data: Any, current_data: Any, base_path: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Initializes an instance of the AigReport class.
|
||||
|
||||
Args:
|
||||
reference_data: The reference data for the report.
|
||||
current_data: The current data for the report.
|
||||
base_path: The base path for the report.
|
||||
"""
|
||||
self.metrics: list[Any] = []
|
||||
self.options: list[Any] | None = None
|
||||
self.sections: dict[str, Any] = {}
|
||||
self.report: Any = None
|
||||
self.ref_data = reference_data
|
||||
self.cur_data = current_data
|
||||
self.set_color_options(primary_color='#0F4C81', secondary_color='#001E60')
|
||||
self.base_path = base_path
|
||||
|
||||
def add_data_quality_section(self, columns: list[str] | None = None, run: bool = True) -> None:
|
||||
"""
|
||||
Adds a data quality section to the report.
|
||||
|
||||
Args:
|
||||
columns: The list of columns to include in the data quality section. If None, all columns will be included.
|
||||
run: Indicates whether to run the report immediately after adding the section.
|
||||
"""
|
||||
metrics = [
|
||||
DatasetSummaryMetric(),
|
||||
generate_column_metrics(ColumnSummaryMetric, columns=columns, skip_id_column=True),
|
||||
ConflictTargetMetric(),
|
||||
DatasetCorrelationsMetric(),
|
||||
]
|
||||
self.metrics.extend(metrics)
|
||||
if run:
|
||||
report = Report(metrics=metrics, options=self.options)
|
||||
report.run(reference_data=self.ref_data, current_data=self.cur_data)
|
||||
self.sections['data_quality'] = report.as_dict()
|
||||
if self.base_path:
|
||||
# Note: Relies on Evidently's save_html() to properly manage file I/O
|
||||
report.save_html(os.path.join(self.base_path, 'data_quality.html'))
|
||||
|
||||
def add_data_drift_section(self, columns: list[str] | None = None, run: bool = True) -> None:
|
||||
"""
|
||||
Adds a data drift section to the report.
|
||||
|
||||
Args:
|
||||
columns: The list of columns to include in the data drift section. If None, all columns will be included.
|
||||
run: Indicates whether to run the report immediately after adding the section.
|
||||
"""
|
||||
self.metrics.append(DataDriftPreset(columns=columns))
|
||||
if run:
|
||||
report = Report(metrics=[DataDriftPreset(columns=columns)], options=self.options)
|
||||
report.run(reference_data=self.ref_data, current_data=self.cur_data)
|
||||
self.sections['data_drift'] = report.as_dict()
|
||||
if self.base_path:
|
||||
# Note: Relies on Evidently's save_html() to properly manage file I/O
|
||||
report.save_html(os.path.join(self.base_path, 'data_drift.html'))
|
||||
|
||||
def add_regression_section(self, run: bool = True) -> None:
|
||||
"""
|
||||
Adds a regression section to the report.
|
||||
|
||||
Args:
|
||||
run: Indicates whether to run the report immediately after adding the section.
|
||||
"""
|
||||
metrics = [
|
||||
RegressionPerformanceMetrics(),
|
||||
RegressionDummyMetric(),
|
||||
RegressionPredictedVsActualScatter(),
|
||||
RegressionPredictedVsActualPlot(),
|
||||
RegressionErrorPlot(),
|
||||
RegressionAbsPercentageErrorPlot(),
|
||||
RegressionErrorDistribution(),
|
||||
]
|
||||
self.metrics.extend(metrics)
|
||||
if run:
|
||||
report = Report(metrics=metrics, options=self.options)
|
||||
report.run(reference_data=self.ref_data, current_data=self.cur_data)
|
||||
self.sections['regression'] = report.as_dict()
|
||||
if self.base_path:
|
||||
# Note: Relies on Evidently's save_html() to properly manage file I/O
|
||||
report.save_html(os.path.join(self.base_path, 'regression.html'))
|
||||
|
||||
def set_color_options(
|
||||
self,
|
||||
primary_color: str = '#0F4C81',
|
||||
secondary_color: str = '#001E60',
|
||||
current_data_color: str | None = None,
|
||||
reference_data_color: str | None = None,
|
||||
additional_data_color: str = '#0a5f38',
|
||||
color_sequence: Sequence[str] = COLOR_DISCRETE_SEQUENCE,
|
||||
fill_color: str = 'LightGreen',
|
||||
zero_line_color: str = 'green',
|
||||
non_visible_color: str = 'white',
|
||||
underestimation_color: str = '#6574f7',
|
||||
overestimation_color: str = '#ee5540',
|
||||
majority_color: str = '#1acc98',
|
||||
vertical_lines: str = 'green',
|
||||
heatmap: str = 'RdBu_r',
|
||||
) -> None:
|
||||
"""
|
||||
Sets the color options for the report.
|
||||
|
||||
Args:
|
||||
primary_color: The primary color for the report.
|
||||
secondary_color: The secondary color for the report.
|
||||
current_data_color: The color for the current data.
|
||||
reference_data_color: The color for the reference data.
|
||||
additional_data_color: The color for additional data.
|
||||
color_sequence: The color sequence for discrete values.
|
||||
fill_color: The fill color for visualizations.
|
||||
zero_line_color: The color for the zero line.
|
||||
non_visible_color: The color for non-visible elements.
|
||||
underestimation_color: The color for underestimation.
|
||||
overestimation_color: The color for overestimation.
|
||||
majority_color: The color for majority elements.
|
||||
vertical_lines: The color for vertical lines.
|
||||
heatmap: The color map for heatmaps.
|
||||
"""
|
||||
color_scheme = ColorOptions(
|
||||
primary_color=primary_color,
|
||||
secondary_color=secondary_color,
|
||||
current_data_color=current_data_color,
|
||||
reference_data_color=reference_data_color,
|
||||
additional_data_color=additional_data_color,
|
||||
color_sequence=color_sequence,
|
||||
fill_color=fill_color,
|
||||
zero_line_color=zero_line_color,
|
||||
non_visible_color=non_visible_color,
|
||||
underestimation_color=underestimation_color,
|
||||
overestimation_color=overestimation_color,
|
||||
majority_color=majority_color,
|
||||
vertical_lines=vertical_lines,
|
||||
heatmap=heatmap,
|
||||
)
|
||||
|
||||
if self.options is None:
|
||||
self.options = [color_scheme]
|
||||
else:
|
||||
self.options.append(color_scheme)
|
||||
|
||||
def save_all_sections_html(self, report_path):
|
||||
"""
|
||||
Saves the report with all sections as HTML.
|
||||
|
||||
Args:
|
||||
report_path: The path to save the report HTML file.
|
||||
|
||||
Raises:
|
||||
ValueError: If base_path is not set
|
||||
OSError: If directory creation or file writing fails
|
||||
|
||||
Note:
|
||||
This method uses context manager (with open) to ensure file is properly closed.
|
||||
Creates parent directories if they don't exist.
|
||||
"""
|
||||
if not self.base_path:
|
||||
raise ValueError('base_path is required to save all sections HTML')
|
||||
|
||||
# Ensure output directory exists
|
||||
output_dir = os.path.dirname(report_path)
|
||||
if output_dir and not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Load main HTML template
|
||||
main_html_path = os.path.join(self.base_path, 'header.html')
|
||||
main_html = load_html_from_file(main_html_path)
|
||||
|
||||
# Load content from data_drift.html, data_quality.html, and regression.html
|
||||
data_drift_content = load_html_from_file(os.path.join(self.base_path, 'data_drift.html'))
|
||||
data_quality_content = load_html_from_file(
|
||||
os.path.join(self.base_path, 'data_quality.html')
|
||||
)
|
||||
regression_content = load_html_from_file(os.path.join(self.base_path, 'regression.html'))
|
||||
|
||||
# Inject content into the main HTML template
|
||||
main_html = inject_content(main_html, 'data_drift', data_drift_content)
|
||||
main_html = inject_content(main_html, 'data_quality', data_quality_content)
|
||||
main_html = inject_content(main_html, 'regression', regression_content)
|
||||
|
||||
# Save the final HTML to a new file (report.html)
|
||||
# Context manager ensures file is properly closed even if an error occurs
|
||||
with open(report_path, 'w', encoding='utf-8') as report_file:
|
||||
report_file.write(main_html)
|
||||
41
model_manager/sientia/utils.py
Normal file
41
model_manager/sientia/utils.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from typing import Any
|
||||
|
||||
from numpy.typing import ArrayLike
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
|
||||
def split_train_test(
|
||||
*data: Any,
|
||||
test_size: float | None = None,
|
||||
train_size: float | None = None,
|
||||
random_state: int | None = None,
|
||||
shuffle: bool = True,
|
||||
stratify: ArrayLike | None = None,
|
||||
) -> tuple[Any, Any, Any, Any]:
|
||||
"""
|
||||
Split arrays or matrices into random train and test subsets.
|
||||
|
||||
Wrapper for sklearn.model_selection.train_test_split.
|
||||
|
||||
Args:
|
||||
*data: data to be split.
|
||||
test_size: size of test subset.
|
||||
train_size: size of train subset.
|
||||
random_state: Seed applied to the data before applying the split.
|
||||
shuffle: Whether or not to shuffle the data before splitting.
|
||||
stratify: If not None, data is split in a stratified fashion, using this as the class labels.
|
||||
|
||||
Returns:
|
||||
X_train, X_test, y_train, y_test
|
||||
|
||||
Thread-safe: This function is stateless and thread-safe.
|
||||
"""
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
*data,
|
||||
test_size=test_size,
|
||||
train_size=train_size,
|
||||
random_state=random_state,
|
||||
shuffle=shuffle,
|
||||
stratify=stratify,
|
||||
)
|
||||
return X_train, X_test, y_train, y_test
|
||||
@@ -1,44 +0,0 @@
|
||||
from pandas import DataFrame
|
||||
|
||||
|
||||
def filter_specific_variables_null_values(data: DataFrame, config: dict) -> bool:
|
||||
"""
|
||||
Filter to check if specific variables contain null values.
|
||||
|
||||
This function examines a DataFrame to determine if any of the specified variables
|
||||
contain null (NaN) values. It returns True if null values are found for any of
|
||||
the specified variables, False otherwise.
|
||||
|
||||
Args:
|
||||
data (DataFrame): The pandas DataFrame to be examined. Must contain columns
|
||||
named 'variable' and 'value'.
|
||||
config (dict): Configuration dictionary containing the following key:
|
||||
- variables (list): List of variable names to check for null values
|
||||
|
||||
Returns:
|
||||
bool: True if any of the specified variables contain null values,
|
||||
False if none of the specified variables contain null values.
|
||||
|
||||
"""
|
||||
return not data[data['variable'].isin(config['variables']) & data['value'].isna()].empty
|
||||
|
||||
|
||||
def filter_empty_data(data: DataFrame, _config: dict) -> bool:
|
||||
"""
|
||||
Filter to check if the DataFrame is empty.
|
||||
|
||||
This function determines whether the provided DataFrame contains any data.
|
||||
It's a simple utility function that can be used in conditional logic to
|
||||
handle cases where no data is available.
|
||||
|
||||
Args:
|
||||
data (DataFrame): The pandas DataFrame to be checked for emptiness.
|
||||
_config (dict): Configuration dictionary (unused in this function).
|
||||
The underscore prefix indicates this parameter is required for
|
||||
interface consistency but not used in the implementation.
|
||||
|
||||
Returns:
|
||||
bool: True if the DataFrame is empty (has no rows), False if it contains data.
|
||||
|
||||
"""
|
||||
return data.empty
|
||||
@@ -1,64 +0,0 @@
|
||||
import numpy as np
|
||||
from pandas import DataFrame
|
||||
|
||||
|
||||
def api_error_filter(response: dict, _config: dict) -> bool:
|
||||
"""
|
||||
Filter MLFlow API responses for error conditions.
|
||||
|
||||
This function analyzes MLFlow API responses to detect error conditions
|
||||
and determine if the response should be filtered out due to quality
|
||||
or reliability issues.
|
||||
|
||||
|
||||
Args:
|
||||
response: MLFlow API response data (dict)
|
||||
_config: Filter configuration dictionary
|
||||
Required keys:
|
||||
- error_codes (list, optional): List of error codes to detect
|
||||
- error_keywords (list, optional): List of error keywords to detect
|
||||
- check_structure (bool, optional): Whether to validate response structure
|
||||
|
||||
Returns:
|
||||
bool: True if data should be filtered (contains errors), False otherwise
|
||||
|
||||
"""
|
||||
if not response:
|
||||
return True
|
||||
|
||||
if not response['success']:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def nan_values_filter(predictions: DataFrame, _config: dict) -> bool:
|
||||
"""
|
||||
Filter data for NaN (Not a Number) values.
|
||||
|
||||
This function detects NaN values in MLFlow prediction results and
|
||||
determines if the data quality is sufficient for further processing
|
||||
or export operations.
|
||||
|
||||
Args:
|
||||
predictions: DataFrame containing prediction data to check for NaN values
|
||||
_config: Filter configuration dictionary
|
||||
Required keys:
|
||||
- max_nan_ratio (float, optional): Maximum allowed NaN value ratio (0.0 to 1.0)
|
||||
- max_nan_count (int, optional): Maximum allowed NaN value count
|
||||
- check_nested (bool, optional): Whether to check nested data structures
|
||||
|
||||
Returns:
|
||||
bool: True if data should be filtered (too many NaN values), False otherwise
|
||||
|
||||
"""
|
||||
data = (
|
||||
predictions.replace({None: np.nan})
|
||||
.infer_objects(copy=False)
|
||||
.drop(columns=['timestamp'], errors='ignore')
|
||||
)
|
||||
|
||||
if data.isna().all().all():
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,9 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
from sientia.linear_models import LinearRegressionModel
|
||||
from sientia.preprocessing import DataPreprocessor
|
||||
|
||||
from model_manager.sientia.models import DataPreprocessor, LinearRegressionModel
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
|
||||
|
||||
@@ -40,8 +39,8 @@ class TrainModelResult:
|
||||
process_data: DataPreprocessor
|
||||
x_train: pd.DataFrame
|
||||
x_test: pd.DataFrame
|
||||
y_train: pd.DataFrame
|
||||
y_test: pd.DataFrame
|
||||
y_train: pd.Series
|
||||
y_test: pd.Series
|
||||
regr: LinearRegressionModel
|
||||
scaler_dict: dict
|
||||
y_pred: pd.Series | None = None
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
"""
|
||||
Model Monitoring Repository
|
||||
MLFlow Repository
|
||||
|
||||
This module contains the ModelMonitoringRepository class,
|
||||
which is responsible for handling the communication with the Model Monitoring API.
|
||||
This module contains the MLFlowRepository class, which is responsible for
|
||||
handling model training artifacts and MLFlow operations for the Model Manager system.
|
||||
|
||||
It includes the methods that are used to answer ModelMonitoringService
|
||||
requests using the Model Monitoring API functions.
|
||||
|
||||
By Monitoring we mean the evaluation of the performance of models, the generation of reports.
|
||||
It includes methods for generating training reports, managing artifacts,
|
||||
and logging model runs to MLFlow.
|
||||
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from os import makedirs, path, remove
|
||||
from os import makedirs, path
|
||||
|
||||
import mlflow
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sientia.ModelServing import ModelServing # type: ignore[import-untyped]
|
||||
from sientia.reports import Reports # type: ignore[import-untyped]
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -34,434 +29,7 @@ class MLFlowRepository:
|
||||
)
|
||||
self.logger = logger
|
||||
|
||||
def detect_and_parse_datetime_index(self, data: pd.DataFrame, metadata: dict) -> pd.DataFrame:
|
||||
"""
|
||||
Detect and parse datetime index from data. index must be a timestamp like column.
|
||||
This function must detect the timestamp type (pandas Timestamp or datetime) and convert it to DATETIME_FORMAT_WITH_TZ.
|
||||
If the index is a string, must be in format DATETIME_FORMAT_WITH_TZ.
|
||||
If another type or format, must raise an error.
|
||||
"""
|
||||
index = data.index
|
||||
|
||||
# Get type of first element of index
|
||||
index_type = type(index[0])
|
||||
|
||||
self.logger.custom_info(f'Index type: {index_type}', metadata)
|
||||
|
||||
message = f'Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format {DATETIME_FORMAT_WITH_TZ}'
|
||||
|
||||
# Check if all in index are of the same type
|
||||
if not all(isinstance(i, index_type) for i in index):
|
||||
raise ValueError(f'{message}')
|
||||
|
||||
# Check type and converts to DATETIME_FORMAT_WITH_TZ
|
||||
if index_type is str:
|
||||
# Validate format of string and return error if not valid
|
||||
try:
|
||||
pd.to_datetime(data.index, format=DATETIME_FORMAT_WITH_TZ)
|
||||
except ValueError as e:
|
||||
raise ValueError(f'{message}') from e
|
||||
|
||||
elif index_type == datetime or index_type == pd.Timestamp:
|
||||
data.index = data.index.strftime(DATETIME_FORMAT_WITH_TZ) # type: ignore[attr-defined]
|
||||
else:
|
||||
raise ValueError(f'{message}')
|
||||
|
||||
return data
|
||||
|
||||
def transform(
|
||||
self, model_name: str, data: pd.DataFrame, model_config: dict, metadata: dict
|
||||
) -> dict:
|
||||
"""
|
||||
Transform data using a model.
|
||||
|
||||
Parameters:
|
||||
- model_name (str): The name of the model to use for transformation.
|
||||
- data (pandas.DataFrame): The data to transform.
|
||||
- model_retention (int): The number of minutes to keep the model.
|
||||
|
||||
Returns:
|
||||
- dict: A dictionary containing the transformed data.
|
||||
"""
|
||||
|
||||
try:
|
||||
self.logger.custom_debug(
|
||||
f'Data received for model transformation: {data.to_csv()}', metadata
|
||||
)
|
||||
|
||||
model_retention = model_config.get('retention_minutes', 0)
|
||||
flavor = model_config.get('transform_flavor', 'sklearn')
|
||||
compressed = model_config.get('is_compressed', False)
|
||||
retention_target = model_config.get('retention_target', 'model')
|
||||
transform_keyword = model_config.get('transform_function_keyword', 'predict')
|
||||
|
||||
transformed_data = self.model_serving.get_cached_transform(
|
||||
model_name,
|
||||
data,
|
||||
model_retention,
|
||||
flavor,
|
||||
compressed,
|
||||
retention_target,
|
||||
transform_keyword,
|
||||
)
|
||||
|
||||
self.logger.custom_debug(
|
||||
f'Data received from model transformation: {transformed_data.to_csv()}', metadata
|
||||
)
|
||||
|
||||
transformed_data = self.detect_and_parse_datetime_index(transformed_data, metadata)
|
||||
|
||||
return {'success': True, 'content': transformed_data.to_dict()}
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
'success': False,
|
||||
'content': {'message': str(e), 'traceback': traceback.format_exc()},
|
||||
}
|
||||
|
||||
def predict(
|
||||
self, model_name: str, data: pd.DataFrame, model_config: dict, metadata: dict
|
||||
) -> dict:
|
||||
"""
|
||||
Predict data using a model.
|
||||
|
||||
Parameters:
|
||||
- model_name (str): The name of the model to use for prediction.
|
||||
- data (pandas.DataFrame): The data to predict.
|
||||
- model_retention (int): The number of minutes to keep the model.
|
||||
|
||||
Returns:
|
||||
- dict: A dictionary containing the predicted data.
|
||||
"""
|
||||
try:
|
||||
model_retention = model_config.get('retention_minutes', 0)
|
||||
flavor = model_config.get('predict_flavor', 'pyfunc')
|
||||
compressed = model_config.get('is_compressed', False)
|
||||
retention_target = model_config.get('retention_target', 'model')
|
||||
|
||||
input_index = data.index
|
||||
start_time = datetime.now()
|
||||
|
||||
self.logger.custom_debug(
|
||||
f'Data received for model prediction: {data.to_csv()}', metadata
|
||||
)
|
||||
data = self.model_serving.get_cached_predict(
|
||||
model_name, data, model_retention, flavor, compressed, retention_target
|
||||
)
|
||||
|
||||
end_time = datetime.now()
|
||||
data = pd.DataFrame(data, columns=['prediction'])
|
||||
self.logger.custom_debug(
|
||||
f'Data received from model prediction: {data.to_csv()}', metadata
|
||||
)
|
||||
data.index = input_index
|
||||
data['response_time'] = (end_time - start_time).total_seconds()
|
||||
|
||||
return {'success': True, 'content': data.to_dict()}
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
'success': False,
|
||||
'content': {'message': str(e), 'traceback': traceback.format_exc()},
|
||||
}
|
||||
|
||||
def get_experiment_by_run_id(self, run_id: str) -> dict:
|
||||
# Get the run information using the run_id
|
||||
run = mlflow.get_run(run_id)
|
||||
|
||||
# Extract the experiment ID from the run
|
||||
experiment_id = run.info.experiment_id
|
||||
|
||||
# Get the experiment details using the experiment ID
|
||||
experiment = mlflow.get_experiment(experiment_id)
|
||||
experiment_name = experiment.name
|
||||
return experiment_name
|
||||
|
||||
def get_next_run_name(self, model_name: str) -> str:
|
||||
"""
|
||||
Generate the next run name for a specific MLFlow model.
|
||||
|
||||
This method calculates the next sequential run number for a model
|
||||
by searching existing runs and incrementing the count. It ensures
|
||||
unique run names for model training and retraining operations.
|
||||
|
||||
Args:
|
||||
model_name (str): The name of the MLFlow model
|
||||
|
||||
Returns:
|
||||
str: The next run name in format 'model_name-run_number'
|
||||
"""
|
||||
runs = mlflow.search_runs(experiment_names=[model_name], order_by=['start_time desc'])
|
||||
next_run_number = len(runs) + 1
|
||||
return f'{model_name}-{next_run_number}'
|
||||
|
||||
def create_model_experiment(self, model_name: str, data: pd.DataFrame) -> tuple:
|
||||
"""
|
||||
Create a new MLFlow experiment for model retraining.
|
||||
|
||||
This method sets up the complete environment for model retraining by:
|
||||
1. Loading the current production prediction model
|
||||
2. Loading the current production transformation model
|
||||
3. Fitting the transformation model with new data
|
||||
4. Preparing data for prediction model retraining
|
||||
5. Setting up the MLFlow experiment context
|
||||
|
||||
Args:
|
||||
model_name (str): Name of the MLFlow model to retrain
|
||||
data (pd.DataFrame): Training data for model retraining
|
||||
|
||||
Returns:
|
||||
tuple: (prediction_model, data_model, experiment)
|
||||
- prediction_model: Loaded prediction model for retraining
|
||||
- data_model: Fitted transformation model
|
||||
- experiment: MLFlow experiment name
|
||||
"""
|
||||
# load predictor model
|
||||
predictor_uri = f'models:/{model_name}/production'
|
||||
# load transform model
|
||||
latest_production_id = self.model_serving.get_model_info(model_name) # type: ignore[no-any-return]
|
||||
transform_uri = self.model_serving.get_model_uri(latest_production_id, prediction=False)
|
||||
# load
|
||||
data_model = mlflow.sklearn.load_model(transform_uri)
|
||||
prediction_model = mlflow.sklearn.load_model(predictor_uri)
|
||||
data_model = data_model.fit(data)
|
||||
treated_data = data_model.predict(data)
|
||||
|
||||
target_name = data_model.target_variable
|
||||
y = data[target_name]
|
||||
treated_data = pd.merge(treated_data, y, left_index=True, right_index=True)
|
||||
prediction_model = prediction_model.fit(treated_data)
|
||||
experiment = self.get_experiment_by_run_id(latest_production_id)
|
||||
mlflow.set_experiment(experiment)
|
||||
|
||||
return prediction_model, data_model, experiment
|
||||
|
||||
def perform_model_retrain(
|
||||
self, prediction_model, data_model, experiment: str, model_name: str, data: pd.DataFrame
|
||||
):
|
||||
"""
|
||||
Execute the complete model retraining process in MLFlow.
|
||||
|
||||
This method performs the actual model retraining by:
|
||||
1. Starting a new MLFlow run with descriptive metadata
|
||||
2. Logging model parameters and hyperparameters
|
||||
3. Retraining both prediction and transformation models
|
||||
4. Logging training data as artifacts
|
||||
5. Saving retrained models to MLFlow registry
|
||||
|
||||
Args:
|
||||
prediction_model: MLFlow prediction model to retrain
|
||||
data_model: MLFlow transformation model to retrain
|
||||
experiment (str): MLFlow experiment name for the retraining
|
||||
model_name (str): Name of the model being retrained
|
||||
data (pd.DataFrame): Training data used for retraining
|
||||
|
||||
Returns:
|
||||
tuple: (status_message, experiment_name)
|
||||
- status_message (str): Success confirmation message
|
||||
- experiment_name (str): Name of the experiment
|
||||
"""
|
||||
pred_model_atributes = vars(prediction_model) # load class attributes
|
||||
data_model_atributes = vars(data_model) # load class attributes
|
||||
experiment_description = f'Retrain model {model_name} with new data'
|
||||
current_run_name = self.get_next_run_name(experiment)
|
||||
with mlflow.start_run(
|
||||
run_name=current_run_name, description=experiment_description
|
||||
) as _run:
|
||||
# update transfomation model
|
||||
# fixed parameters
|
||||
for name_atribute, val_atribute in pred_model_atributes.items():
|
||||
if name_atribute != 'model':
|
||||
mlflow.log_param(name_atribute, val_atribute)
|
||||
# update prediction model
|
||||
for name_atribute, val_atribute in data_model_atributes.items():
|
||||
if name_atribute != 'model':
|
||||
mlflow.log_param(name_atribute, val_atribute)
|
||||
# dynamic parameters, including model itself
|
||||
mlflow.sklearn.log_model(data_model, 'data_model')
|
||||
|
||||
makedirs('temp', exist_ok=True)
|
||||
|
||||
file_path = f'temp/raw_data_{model_name}.csv'
|
||||
data.to_csv(file_path, index=True)
|
||||
|
||||
# log the data raw
|
||||
mlflow.log_artifact(file_path)
|
||||
|
||||
# dynamic parameters, including model itself
|
||||
mlflow.sklearn.log_model(prediction_model, 'prediction_model')
|
||||
mlflow.log_param('retrain', True)
|
||||
|
||||
# clear temp file
|
||||
if path.exists(file_path):
|
||||
remove(file_path)
|
||||
|
||||
return 'Model retrained successfully', experiment
|
||||
|
||||
def retrain_model(self, data: pd.DataFrame, model_name: str) -> tuple:
|
||||
"""
|
||||
Orchestrate the complete model retraining workflow.
|
||||
|
||||
This method coordinates the entire model retraining process by:
|
||||
1. Creating the MLFlow experiment environment
|
||||
2. Loading existing production models
|
||||
3. Executing the retraining process
|
||||
4. Returning comprehensive retraining results
|
||||
|
||||
Args:
|
||||
data (pd.DataFrame): Training data for model retraining
|
||||
model_name (str): Name of the MLFlow model to retrain
|
||||
|
||||
Returns:
|
||||
tuple: (status_message, experiment_name)
|
||||
- status_message (str): Retraining operation status
|
||||
- experiment_name (str): MLFlow experiment identifier
|
||||
"""
|
||||
prediction_model, data_model, experiment = self.create_model_experiment(model_name, data)
|
||||
retrain_result = self.perform_model_retrain(
|
||||
prediction_model, data_model, experiment, model_name, data
|
||||
)
|
||||
return retrain_result
|
||||
|
||||
def get_experiment(self, experiment_name: str) -> int:
|
||||
"""
|
||||
Retrieve MLFlow experiment ID by experiment name.
|
||||
|
||||
This method searches for an MLFlow experiment by name and
|
||||
returns its unique identifier. It provides error handling
|
||||
for non-existent experiments.
|
||||
|
||||
Args:
|
||||
experiment_name (str): Name of the MLFlow experiment
|
||||
|
||||
Returns:
|
||||
int: MLFlow experiment ID
|
||||
|
||||
Raises:
|
||||
ValueError: If the experiment name is not found
|
||||
"""
|
||||
experiment = mlflow.get_experiment_by_name(experiment_name)
|
||||
if experiment is None:
|
||||
raise ValueError(f'Experiment {experiment_name} not found')
|
||||
|
||||
return experiment.experiment_id # type: ignore[no-any-return]
|
||||
|
||||
def get_experiment_last_run(self, experiment_id: int) -> str:
|
||||
"""
|
||||
Retrieve the most recent retraining run ID for an experiment.
|
||||
|
||||
This method searches for the latest run in an MLFlow experiment
|
||||
that has been marked as a retraining run. It filters runs by
|
||||
the 'retrain' parameter and orders them by completion time.
|
||||
|
||||
Args:
|
||||
experiment_id (int): MLFlow experiment ID
|
||||
|
||||
Returns:
|
||||
str: MLFlow run ID of the most recent retraining run
|
||||
|
||||
Raises:
|
||||
ValueError: If runs data is not in expected DataFrame format
|
||||
"""
|
||||
runs = mlflow.search_runs(
|
||||
experiment_ids=[experiment_id],
|
||||
filter_string='', # Sem filtro no MLflow ainda
|
||||
output_format='pandas',
|
||||
)
|
||||
|
||||
if not isinstance(runs, pd.DataFrame):
|
||||
raise ValueError('Runs is not a pandas DataFrame')
|
||||
|
||||
# Filtrar apenas as runs onde params.retrain == True
|
||||
filtered_runs = runs[runs['params.retrain'] == 'True']
|
||||
|
||||
# Converter a coluna 'end_time' para datetime
|
||||
filtered_runs['end_time'] = pd.to_datetime(filtered_runs['end_time'])
|
||||
|
||||
# Ordenar o DataFrame de forma descendente pela coluna 'end_time'
|
||||
filtered_runs = filtered_runs.sort_values(by='end_time', ascending=False)
|
||||
|
||||
# Pegar a última run_id do DataFrame filtrado e ordenado
|
||||
latest_run_id = filtered_runs.iloc[0]['run_id']
|
||||
|
||||
return latest_run_id
|
||||
|
||||
def update_production_model_by_run_id(self, run_id: str, model_name: str) -> dict:
|
||||
"""
|
||||
Update production model with a specific MLFlow run.
|
||||
|
||||
This method promotes a model from a specific MLFlow run to
|
||||
production stage. It handles model registration, versioning,
|
||||
and stage transitions with proper error handling.
|
||||
|
||||
Args:
|
||||
run_id (str): MLFlow run ID containing the model to promote
|
||||
model_name (str): Name of the MLFlow model
|
||||
|
||||
Returns:
|
||||
dict: Model update metadata containing:
|
||||
- model_name (str): Name of the updated model
|
||||
- version (str): New model version number
|
||||
- mlflow_run_id (str): Source run ID
|
||||
|
||||
Update Process:
|
||||
1. Registers the model from the specified run
|
||||
2. Retrieves the latest model version
|
||||
3. Transitions the model to 'Production' stage
|
||||
4. Archives existing production versions
|
||||
"""
|
||||
# Registrar o modelo
|
||||
# Aqui estamos assumindo que você já tem um modelo salvo, caso contrário você precisará treiná-lo e salvá-lo primeiro.
|
||||
# Se o modelo já está registrado, você pode usar o método register_model() ou pyfunc.load_model() para isso.
|
||||
mlflow.register_model(f'runs:/{run_id}/prediction_model', model_name)
|
||||
|
||||
# Colocar a versão do modelo em produção
|
||||
# Depois de registrar o modelo, precisamos pegar a versão mais recente do modelo e movê-lo para o estágio 'Production'
|
||||
client = mlflow.tracking.MlflowClient()
|
||||
|
||||
# Obter a versão mais recente registrada do modelo
|
||||
model_versions = client.get_registered_model(model_name).latest_versions
|
||||
|
||||
if not isinstance(model_versions, list):
|
||||
raise ValueError('Model versions is not a list')
|
||||
|
||||
max_version = max(model_versions, key=lambda x: int(x.version)).version
|
||||
|
||||
# Mover a versão mais recente do modelo para o estágio de 'Production'
|
||||
client.transition_model_version_stage(
|
||||
name=model_name, version=max_version, stage='Production', archive_existing_versions=True
|
||||
)
|
||||
|
||||
return {'model_name': model_name, 'version': max_version, 'mlflow_run_id': run_id}
|
||||
|
||||
def update_production_model(self, experiment: str, model_name: str) -> dict:
|
||||
"""
|
||||
Update production model using the latest retraining run.
|
||||
|
||||
This method orchestrates the complete production model update
|
||||
process by identifying the most recent retraining run and
|
||||
promoting it to production stage.
|
||||
|
||||
Args:
|
||||
experiment (str): MLFlow experiment name
|
||||
model_name (str): Name of the MLFlow model
|
||||
|
||||
Returns:
|
||||
dict: Complete model update metadata containing:
|
||||
- model_name (str): Name of the updated model
|
||||
- version (str): New model version number
|
||||
- mlflow_run_id (str): Source run ID
|
||||
- mlflow_experiment_id (int): Experiment ID
|
||||
"""
|
||||
experiment_id = self.get_experiment(experiment)
|
||||
run_id = self.get_experiment_last_run(experiment_id)
|
||||
metadata = self.update_production_model_by_run_id(run_id, model_name)
|
||||
|
||||
metadata['mlflow_experiment_id'] = experiment_id
|
||||
|
||||
return metadata
|
||||
|
||||
def get_next_run_name_new(self, experiment_name: str) -> str:
|
||||
def get_next_run_name(self, experiment_name: str) -> str:
|
||||
"""
|
||||
Generates the next run name for a given experiment.
|
||||
|
||||
|
||||
@@ -10,14 +10,13 @@ from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sientia.linear_models import LinearRegressionModel
|
||||
from sientia.metrics import mae, mse, r2
|
||||
from sientia.preprocessing import DataPreprocessor
|
||||
from sientia.utils import split_train_test
|
||||
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.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
|
||||
|
||||
@@ -174,7 +173,7 @@ class TrainingRepository:
|
||||
and metrics (mse_val, mae_val, r2_val)
|
||||
"""
|
||||
# Make predictions on test set
|
||||
tmr.y_pred = tmr.regr.predict(tmr.x_test)
|
||||
y_pred_array = tmr.regr.predict(tmr.x_test)
|
||||
|
||||
# Denormalize data if scaler was used
|
||||
if params.use_scaler:
|
||||
@@ -188,10 +187,10 @@ class TrainingRepository:
|
||||
# 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)
|
||||
tmr.y_pred = scaler.denormalize_predictions(tmr.y_pred, params.target_variable)
|
||||
y_pred_array = scaler.denormalize_predictions(y_pred_array, params.target_variable)
|
||||
|
||||
# Add index to predictions
|
||||
tmr.y_pred = pd.Series(tmr.y_pred, index=tmr.y_test.index)
|
||||
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
|
||||
@@ -202,6 +201,7 @@ class TrainingRepository:
|
||||
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,
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"""
|
||||
Model Manager Worker Module
|
||||
"""Model Manager Worker Module
|
||||
|
||||
This module provides the main worker implementation for the Sientia DataOps Model Manager system.
|
||||
It orchestrates Temporal workers, manages task queues, and handles the lifecycle of
|
||||
prediction and retraining workflows.
|
||||
model training workflows.
|
||||
|
||||
The worker supports two main task queues:
|
||||
- predictions_batch-queue: Handles batch prediction workflows
|
||||
- minimal_retrain-queue: Handles model retraining workflows
|
||||
The worker supports the train_model-queue task queue for ML model training workflows.
|
||||
|
||||
Key Features:
|
||||
- Automatic scaling with PollerBehaviorAutoscaling
|
||||
- Prometheus metrics integration
|
||||
- Comprehensive error handling and logging
|
||||
- Graceful shutdown with cleanup
|
||||
- Multiple worker instances for different workflow types
|
||||
- ML model training pipeline orchestration
|
||||
|
||||
Environment Variables:
|
||||
- TEMPORAL_HOST: Temporal server address (default: localhost:7233)
|
||||
@@ -46,12 +43,7 @@ with workflow.unsafe.imports_passed_through():
|
||||
build_mongodb_config,
|
||||
build_postgres_config,
|
||||
)
|
||||
from model_manager.workflows.minimal_retrain import MinimalRetrain
|
||||
from model_manager.workflows.predictions_batch import PredictionsBatch
|
||||
from model_manager.workflows.sub_workflows.format_and_export_prediction import (
|
||||
FormatAndExportPrediction,
|
||||
)
|
||||
from model_manager.workflows.sub_workflows.prediction_process import PredictionProcess
|
||||
from model_manager.workflows.train_model import TrainModel
|
||||
|
||||
POD_ID = os.getenv('POD_ID')
|
||||
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091'))
|
||||
@@ -133,41 +125,21 @@ async def main():
|
||||
workers = [
|
||||
Worker(
|
||||
temporal_client,
|
||||
task_queue='minimal_retrain-queue',
|
||||
workflows=[MinimalRetrain],
|
||||
activities=[
|
||||
activities.load_custom_query,
|
||||
activities.retrain_model,
|
||||
activities.update_production_model,
|
||||
activities.export_data_to_postgres,
|
||||
],
|
||||
max_concurrent_workflow_tasks=50,
|
||||
max_concurrent_activities=50,
|
||||
max_concurrent_local_activities=50,
|
||||
max_cached_workflows=200,
|
||||
workflow_task_poller_behavior=PollerBehaviorAutoscaling(),
|
||||
activity_task_poller_behavior=PollerBehaviorAutoscaling(),
|
||||
),
|
||||
Worker(
|
||||
temporal_client,
|
||||
task_queue='predictions_batch-queue',
|
||||
workflows=[PredictionsBatch, PredictionProcess, FormatAndExportPrediction],
|
||||
task_queue='train_model-queue',
|
||||
workflows=[TrainModel],
|
||||
activities=[
|
||||
# Training & Validation
|
||||
activities.validate_train_params,
|
||||
activities.train_model,
|
||||
# MLFlow
|
||||
activities.request_predict,
|
||||
activities.request_transform,
|
||||
# Gates
|
||||
activities.input_gate,
|
||||
activities.mlflow_response_gate,
|
||||
activities.mlflow_content_gate,
|
||||
activities.format_prediction,
|
||||
activities.format_default_prediction,
|
||||
activities.get_last_timestamp,
|
||||
# Postgres
|
||||
activities.load_custom_query,
|
||||
activities.repeat_last_prediction,
|
||||
activities.export_data_to_postgres,
|
||||
activities.write_metrics,
|
||||
activities.save_model,
|
||||
# MinIO
|
||||
activities.fetch_file_from_minio,
|
||||
activities.delete_file_from_minio,
|
||||
# Filesystem
|
||||
activities.cleanup_run_directory,
|
||||
# Database
|
||||
activities.update_experiment_run,
|
||||
],
|
||||
max_concurrent_workflow_tasks=50,
|
||||
max_concurrent_activities=50,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.temporal.policies import retry_policy
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
|
||||
|
||||
@workflow.defn(name='minimal_retrain')
|
||||
class MinimalRetrain:
|
||||
"""
|
||||
Automated model retraining workflow for the Model Manager system.
|
||||
|
||||
This workflow implements a complete model retraining pipeline that loads
|
||||
training data, executes model retraining, updates production models,
|
||||
and maintains comprehensive audit trails. It's designed for automated
|
||||
model lifecycle management with minimal manual intervention.
|
||||
|
||||
The workflow provides a robust retraining process with:
|
||||
- Automated data loading from configured data sources
|
||||
- MLFlow model retraining with quality validation
|
||||
- Production model updates with version control
|
||||
- Comprehensive reporting and audit trail maintenance
|
||||
- Error handling and notification integration
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
"""
|
||||
Execute the automated model retraining workflow.
|
||||
|
||||
This method orchestrates the complete model retraining process by:
|
||||
1. Loading training data using the provided custom SQL query
|
||||
2. Executing MLFlow model retraining with the loaded data
|
||||
3. Updating production models with newly trained versions
|
||||
4. Persisting comprehensive retraining reports to database
|
||||
|
||||
The method implements comprehensive error handling and ensures all
|
||||
required parameters are properly configured before proceeding.
|
||||
|
||||
Args:
|
||||
input_data: Complete configuration for the retraining workflow
|
||||
Required keys:
|
||||
- schedule_name (str): Schedule identifier for the retraining
|
||||
- model_name (str): Name of the ML model to retrain
|
||||
- model_id (int): Unique identifier for the model version
|
||||
- query (str): SQL query for training data loading
|
||||
- schema (str, optional): Database schema for report storage
|
||||
- table_name (str, optional): Target table for retraining reports
|
||||
- datetime_columns (list[str], optional): Columns to treat as datetime
|
||||
|
||||
Returns:
|
||||
None: The workflow completes successfully when all steps finish
|
||||
|
||||
Raises:
|
||||
Exception: If any required parameters are missing or if the workflow fails
|
||||
during data loading, retraining, or model update operations
|
||||
"""
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'schedule_name': input_data['schedule_name'],
|
||||
'model_name': input_data['model_name'],
|
||||
'model_id': input_data['model_id'],
|
||||
'workflow_name': 'minimal_retrain',
|
||||
}
|
||||
}
|
||||
|
||||
model_name = input_data['model_name']
|
||||
|
||||
data = await workflow.execute_local_activity_method(
|
||||
Activities.load_custom_query,
|
||||
{
|
||||
**metadata,
|
||||
'query': input_data['query'],
|
||||
'datetime_columns': input_data.get('datetime_columns', []),
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
experiment_response = await workflow.execute_activity_method(
|
||||
Activities.retrain_model,
|
||||
{**metadata, 'data': data, 'model_name': model_name},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
report = await workflow.execute_activity_method(
|
||||
Activities.update_production_model,
|
||||
{
|
||||
**metadata,
|
||||
'model_name': model_name,
|
||||
'model_id': input_data['model_id'],
|
||||
**experiment_response,
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
await workflow.execute_activity_method(
|
||||
Activities.export_data_to_postgres,
|
||||
{
|
||||
**metadata,
|
||||
'data': report,
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.temporal.policies import retry_policy
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
|
||||
|
||||
@workflow.defn(name='predictions_batch')
|
||||
class PredictionsBatch:
|
||||
"""
|
||||
Main batch prediction workflow for the Model Manager system.
|
||||
|
||||
This workflow orchestrates the complete batch prediction process, handling
|
||||
data loading, configuration management, and workflow delegation. It serves
|
||||
as the primary entry point for batch prediction operations and ensures
|
||||
proper data preparation before ML model inference.
|
||||
|
||||
The workflow implements a robust data processing pipeline with:
|
||||
- Custom SQL query execution for data loading
|
||||
- Comprehensive configuration management
|
||||
- Data quality filter application
|
||||
- MLFlow model integration
|
||||
- Workflow delegation to specialized sub-workflows
|
||||
|
||||
Workflow Execution:
|
||||
1. Data Loading: Executes custom SQL query to load prediction data
|
||||
2. Configuration Preparation: Sets up prediction parameters and filters
|
||||
3. Workflow Delegation: Spawns PredictionProcess child workflow
|
||||
4. Error Handling: Implements comprehensive error handling and retry policies
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
"""
|
||||
Execute the batch prediction workflow.
|
||||
|
||||
This method orchestrates the complete batch prediction process by:
|
||||
1. Loading data using the provided custom SQL query
|
||||
2. Preparing prediction configuration and filters
|
||||
3. Delegating to the PredictionProcess workflow for ML operations
|
||||
|
||||
The method implements comprehensive error handling and ensures all
|
||||
required parameters are properly configured before proceeding.
|
||||
|
||||
Args:
|
||||
input_data: Complete configuration for the batch prediction
|
||||
Required keys:
|
||||
- schedule_name (str): Schedule identifier for the prediction
|
||||
- model_name (str): Name of the ML model to use
|
||||
- model_id (int): Unique identifier for the model
|
||||
- query (str): SQL query for data loading
|
||||
- schema (dict, optional): Data schema definition
|
||||
- table_name (str, optional): Target table for predictions
|
||||
- input_filters (dict, optional): Data quality filters
|
||||
- mlflow_transform_filters (dict, optional): MLFlow transform filters
|
||||
- mlflow_predict_filters (dict, optional): MLFlow prediction filters
|
||||
- model_retention (int, optional): Model retention period in minutes
|
||||
- path_priority (list[str]): Decision path priority configuration
|
||||
|
||||
- datetime_columns (list[str], optional): Columns to treat as datetime
|
||||
|
||||
Returns:
|
||||
None: The workflow completes successfully when the child workflow finishes
|
||||
|
||||
Raises:
|
||||
Exception: If any required parameters are missing or if the workflow fails
|
||||
during data loading or workflow delegation
|
||||
"""
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'schedule_name': input_data['schedule_name'],
|
||||
'model_name': input_data['model_name'],
|
||||
'model_id': input_data['model_id'],
|
||||
'workflow_name': 'predictions_batch',
|
||||
}
|
||||
}
|
||||
|
||||
# Load data using custom query
|
||||
data = await workflow.execute_local_activity_method(
|
||||
Activities.load_custom_query,
|
||||
{
|
||||
**metadata,
|
||||
'query': input_data['query'],
|
||||
'datetime_columns': input_data.get('datetime_columns', []),
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=300),
|
||||
)
|
||||
|
||||
# Prepare input for prediction_process workflow
|
||||
prediction_input = {
|
||||
'metadata': metadata,
|
||||
'data': data,
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'model_id': input_data['model_id'],
|
||||
'model_name': input_data['model_name'],
|
||||
'input_filters': input_data.get('input_filters', {'EMPTY_DATA': {'POLICY': 'STOP'}}),
|
||||
'mlflow_transform_filters': input_data.get(
|
||||
'mlflow_transform_filters', {'API_ERROR': {'POLICY': 'STOP'}}
|
||||
),
|
||||
'mlflow_predict_filters': input_data.get(
|
||||
'mlflow_predict_filters', {'API_ERROR': {'POLICY': 'STOP'}}
|
||||
),
|
||||
'model_config': input_data.get('model_config', {}),
|
||||
'path_priority': input_data.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT']),
|
||||
'prediction_store_policy': input_data.get('prediction_store_policy', 'lts:1'),
|
||||
}
|
||||
|
||||
# Execute prediction process workflow
|
||||
await workflow.execute_child_workflow('prediction_process', prediction_input)
|
||||
@@ -1,120 +0,0 @@
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||
from sientia_do.temporal.policies import retry_policy
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
|
||||
|
||||
@workflow.defn(name='format_and_export_prediction')
|
||||
class FormatAndExportPrediction:
|
||||
"""
|
||||
Data formatting and export workflow for prediction results.
|
||||
|
||||
This workflow handles the final stages of the prediction pipeline, including
|
||||
data formatting, database persistence, and metrics recording.
|
||||
It implements flexible formatting based on prediction quality and provides
|
||||
comprehensive export capabilities.
|
||||
|
||||
The workflow supports two main prediction paths:
|
||||
1. Normal Prediction: Formats and exports successful prediction results
|
||||
2. Default Prediction: Creates fallback predictions for error conditions
|
||||
|
||||
Export Destinations:
|
||||
- PostgreSQL Database: Persistent storage with timestamp conversion
|
||||
- Prometheus Metrics: Performance monitoring and operational visibility
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
"""
|
||||
Execute the prediction formatting and export workflow.
|
||||
|
||||
This method orchestrates the complete data export process by:
|
||||
1. Determining the appropriate formatting strategy based on path_flag
|
||||
2. Formatting prediction data according to quality and requirements
|
||||
3. Persisting data to PostgreSQL database with comprehensive metadata
|
||||
4. Recording performance metrics for operational monitoring
|
||||
|
||||
The method implements flexible formatting strategies:
|
||||
- Normal predictions: Full data formatting with confidence scores
|
||||
- Error predictions: Default formatting with error indicators
|
||||
|
||||
Args:
|
||||
input_data: Complete configuration for the export workflow
|
||||
Required keys:
|
||||
- path_flag (str | None): Decision path flag for formatting strategy
|
||||
- data (dict[str, Any]): Prediction data to format and export
|
||||
- prediction_confidence (float): Confidence score for the prediction
|
||||
- timestamp (str): ISO-formatted timestamp for the prediction
|
||||
- model_id (int): Unique identifier for the ML model
|
||||
- model_name (str): Name of the ML model
|
||||
- model_retention (str): Model retention policy configuration
|
||||
- comment (str): Operational comment or error description
|
||||
- schema (str): Database schema for data storage
|
||||
- table_name (str): Target table for data persistence
|
||||
- prediction_store_policy (str, optional): Data retention policy
|
||||
|
||||
Returns:
|
||||
bool: True if the workflow completes successfully, False otherwise
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
path_flag = input_data['path_flag']
|
||||
data = input_data['data']
|
||||
prediction_confidence = input_data['prediction_confidence']
|
||||
|
||||
if path_flag is None:
|
||||
# proceed with formatting and exporting
|
||||
prediction = await workflow.execute_local_activity_method(
|
||||
Activities.format_prediction,
|
||||
{
|
||||
**metadata,
|
||||
'data': data,
|
||||
'timestamp': input_data['timestamp'],
|
||||
'model_id': input_data['model_id'],
|
||||
'prediction_confidence': prediction_confidence,
|
||||
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
else:
|
||||
# create default prediction
|
||||
prediction = await workflow.execute_local_activity_method(
|
||||
Activities.format_default_prediction,
|
||||
{
|
||||
**metadata,
|
||||
'timestamp': input_data['timestamp'],
|
||||
'model_id': input_data['model_id'],
|
||||
'prediction_confidence': prediction_confidence,
|
||||
'comment': input_data['comment'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
# write to postgres
|
||||
await workflow.execute_activity_method(
|
||||
Activities.export_data_to_postgres,
|
||||
{
|
||||
**metadata,
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'data': prediction,
|
||||
'timestamp_conversion': {'column': 'timestamp', 'format': DATETIME_FORMAT_WITH_TZ},
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
await workflow.execute_activity_method(
|
||||
Activities.write_metrics,
|
||||
{**metadata, 'prediction': prediction},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
@@ -1,295 +0,0 @@
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sientia_do.temporal.policies import retry_policy
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
|
||||
|
||||
@workflow.defn(name='prediction_process')
|
||||
class PredictionProcess:
|
||||
"""
|
||||
Core prediction processing workflow for the Model Manager system.
|
||||
|
||||
This workflow implements the complete ML model inference pipeline, handling
|
||||
data quality validation, MLFlow model interactions, and prediction processing.
|
||||
It serves as the central orchestrator for all prediction operations and ensures
|
||||
data quality throughout the entire process.
|
||||
|
||||
The workflow implements a robust data processing pipeline with:
|
||||
- Data quality validation using configurable filters
|
||||
- MLFlow model transformation and prediction
|
||||
- Response validation and quality assurance
|
||||
- Flexible decision path handling
|
||||
- Comprehensive error handling and retry policies
|
||||
|
||||
Workflow Execution:
|
||||
1. Timestamp Retrieval: Gets last processed timestamp for incremental processing
|
||||
2. Input Data Gate: Applies data quality filters
|
||||
3. Path Decision: Determines processing path based on filter results
|
||||
4. MLFlow Transform: Requests data transformation using MLFlow models
|
||||
5. Response Validation: Filters transform responses for quality assurance
|
||||
6. MLFlow Prediction: Executes prediction using transformed data
|
||||
7. Content Validation: Filters prediction responses for final quality check
|
||||
8. Export Delegation: Delegates to FormatAndExportPrediction workflow
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
"""
|
||||
Execute the prediction process workflow.
|
||||
|
||||
This method orchestrates the complete prediction processing pipeline by:
|
||||
1. Retrieving the last processed timestamp for incremental processing
|
||||
2. Applying data quality filters to validate input data
|
||||
3. Executing MLFlow model transformation and prediction
|
||||
4. Validating all responses for quality assurance
|
||||
5. Delegating to export workflow for data persistence
|
||||
|
||||
The method implements comprehensive error handling and ensures all
|
||||
data quality requirements are met before proceeding with ML operations.
|
||||
|
||||
Args:
|
||||
input_data: Complete configuration for the prediction process
|
||||
Required keys:
|
||||
- metadata (dict): Workflow execution metadata
|
||||
- data (dict): Input data for prediction processing
|
||||
- schema (dict): Data schema definition
|
||||
- table_name (str): Target table for predictions
|
||||
- model_id (str): ML model identifier
|
||||
- model_name (str): ML model name
|
||||
- input_filters (dict): Data quality filters
|
||||
- mlflow_transform_filters (dict): MLFlow transform filters
|
||||
- mlflow_predict_filters (dict): MLFlow prediction filters
|
||||
- model_retention (int): Model retention period in minutes
|
||||
- path_priority (list[str]): Decision path priority configuration
|
||||
|
||||
|
||||
Returns:
|
||||
None: The workflow completes successfully when export workflow finishes
|
||||
|
||||
Raises:
|
||||
Exception: If any required parameters are missing or if the workflow fails
|
||||
during data processing, MLFlow operations, or workflow delegation
|
||||
|
||||
"""
|
||||
|
||||
metadata = input_data['metadata']
|
||||
data = input_data['data']
|
||||
model_id = input_data['model_id']
|
||||
model_name = input_data['model_name']
|
||||
model_config = input_data.get('model_config', {})
|
||||
|
||||
# Get last timestamp for incremental processing
|
||||
last_timestamp = await workflow.execute_local_activity_method(
|
||||
Activities.get_last_timestamp,
|
||||
{**metadata, 'data': data},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
# Apply input data quality gates
|
||||
gate_input = {
|
||||
**metadata,
|
||||
'filters': input_data['input_filters'],
|
||||
'data': data,
|
||||
'path_priority': input_data['path_priority'],
|
||||
}
|
||||
|
||||
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
||||
Activities.input_gate,
|
||||
gate_input,
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
# Handle path decision based on filter results
|
||||
if await self.path_flag_handler(
|
||||
data, path_flag, input_data, confidence, last_timestamp, comment
|
||||
):
|
||||
return
|
||||
|
||||
# Request MLFlow model transformation
|
||||
response_data = await workflow.execute_local_activity_method(
|
||||
Activities.request_transform,
|
||||
{**metadata, 'data': data, 'model_name': model_name, 'model_config': model_config},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
# Validate MLFlow transform response
|
||||
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': response_data,
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
# Handle path decision based on transform validation
|
||||
if await self.path_flag_handler(
|
||||
data, path_flag, input_data, confidence, last_timestamp, comment
|
||||
):
|
||||
return
|
||||
|
||||
transformed_data = response_data['content']
|
||||
|
||||
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
||||
Activities.mlflow_content_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': transformed_data,
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
if await self.path_flag_handler(
|
||||
data, path_flag, input_data, confidence, last_timestamp, comment
|
||||
):
|
||||
return
|
||||
|
||||
response_data = await workflow.execute_local_activity_method(
|
||||
Activities.request_predict,
|
||||
{
|
||||
**metadata,
|
||||
'data': transformed_data,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
# Validate MLFlow prediction response
|
||||
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['mlflow_predict_filters'],
|
||||
'data': response_data,
|
||||
'type': 'predict',
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
# Handle path decision based on prediction validation
|
||||
if await self.path_flag_handler(
|
||||
data, path_flag, input_data, confidence, last_timestamp, comment
|
||||
):
|
||||
return
|
||||
|
||||
# Delegate to export workflow for data persistence
|
||||
await workflow.execute_child_workflow(
|
||||
'format_and_export_prediction',
|
||||
{
|
||||
'metadata': metadata,
|
||||
'path_flag': path_flag,
|
||||
'data': response_data['content'],
|
||||
'prediction_confidence': confidence,
|
||||
'timestamp': last_timestamp,
|
||||
'model_id': model_id,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'comment': comment,
|
||||
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||
},
|
||||
)
|
||||
|
||||
async def path_flag_handler(
|
||||
self,
|
||||
data: dict,
|
||||
path_flag: str,
|
||||
input_data: dict,
|
||||
confidence: int,
|
||||
last_timestamp: str,
|
||||
comment: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Handle path decisions based on filter results and confidence levels.
|
||||
|
||||
This method determines the appropriate action based on the path flag
|
||||
returned by data quality filters. It can stop processing, continue,
|
||||
or repeat operations based on the configured path priority.
|
||||
|
||||
Args:
|
||||
data: Input data for processing
|
||||
path_flag: Path decision from filter (STOP, CONTINUE, REPEAT)
|
||||
input_data: Complete workflow input configuration
|
||||
confidence: Confidence level from filter validation
|
||||
last_timestamp: Last processed timestamp
|
||||
comment: Additional information about the filter result
|
||||
|
||||
Returns:
|
||||
bool: True if processing should stop, False to continue
|
||||
|
||||
Path Handling:
|
||||
- STOP: Terminates workflow execution
|
||||
- CONTINUE: Proceeds with normal processing
|
||||
- REPEAT: Repeats last prediction if available
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
|
||||
schema = input_data['schema']
|
||||
table_name = input_data['table_name']
|
||||
model_id = input_data['model_id']
|
||||
model_name = input_data['model_name']
|
||||
model_config = input_data.get('model_config', {})
|
||||
|
||||
path_flag = path_flag.upper() if path_flag else ''
|
||||
|
||||
if path_flag == 'STOP':
|
||||
# Stop processing and exit workflow
|
||||
return True
|
||||
elif path_flag == 'REPEAT':
|
||||
# Repeat last prediction if available
|
||||
await workflow.execute_activity_method(
|
||||
Activities.repeat_last_prediction,
|
||||
{
|
||||
**metadata,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'model': model_id,
|
||||
'last_timestamp': last_timestamp,
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(minutes=1),
|
||||
)
|
||||
return True
|
||||
elif path_flag == 'CONTINUE':
|
||||
# call write workflow
|
||||
await workflow.execute_child_workflow(
|
||||
'format_and_export_prediction',
|
||||
{
|
||||
'metadata': metadata,
|
||||
'path_flag': path_flag,
|
||||
'data': data,
|
||||
'prediction_confidence': confidence,
|
||||
'timestamp': last_timestamp,
|
||||
'model_id': model_id,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'comment': comment,
|
||||
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -98,11 +98,23 @@ module = "prometheus_client.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "sientia.*"
|
||||
module = "pandas.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "pandas.*"
|
||||
module = "bs4.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "evidently.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "sklearn.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "yaml"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
temporalio
|
||||
psycopg2-binary
|
||||
sqlalchemy
|
||||
boto3
|
||||
botocore
|
||||
temporalio==1.18.1
|
||||
psycopg2-binary==2.9.11
|
||||
sqlalchemy==2.0.44
|
||||
boto3==1.40.55
|
||||
botocore==1.40.55
|
||||
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.4.6
|
||||
git+ssh://git@github.com/Aignosi/sientia-mlops-library.git@0.39.0
|
||||
prometheus-client
|
||||
prometheus-client==0.23.1
|
||||
mlflow==2.10.1
|
||||
evidently==0.4.21
|
||||
beautifulsoup4==4.12.3
|
||||
scikit-learn==1.4.2
|
||||
|
||||
111
setup_port_forwards.sh
Executable file
111
setup_port_forwards.sh
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== Port Forward Setup Script ===${NC}\n"
|
||||
|
||||
# Define port forwards: LOCAL_PORT:NAMESPACE:SERVICE:REMOTE_PORT:DESCRIPTION
|
||||
PORT_FORWARDS=(
|
||||
"5432:paradedb:paradedb-rw:5432:PostgreSQL"
|
||||
"45249:sientia-tracker:sientia-tracker-mlflow-tracking:80:MLflow"
|
||||
"37463:temporal:temporal-frontend:7233:Temporal"
|
||||
"8080:temporal:temporal-web:8080:Temporal UI"
|
||||
"42297:mongodb:my-release-mongodb:27017:MongoDB"
|
||||
"36577:minio:minio:9000:MinIO"
|
||||
)
|
||||
|
||||
# Step 1: Kill existing port-forward jobs for these services
|
||||
echo -e "${YELLOW}Step 1: Checking for existing port-forward jobs...${NC}"
|
||||
|
||||
for pf in "${PORT_FORWARDS[@]}"; do
|
||||
IFS=':' read -r local_port namespace service remote_port description <<< "$pf"
|
||||
|
||||
# Check if there's a job with this service name
|
||||
existing_jobs=$(jobs -l | grep "kubectl.*port-forward.*svc/$service" || true)
|
||||
|
||||
if [ -n "$existing_jobs" ]; then
|
||||
echo -e "${YELLOW} Found existing port-forward for $description ($service)${NC}"
|
||||
# Extract PIDs and kill them
|
||||
pids=$(echo "$existing_jobs" | awk '{print $2}')
|
||||
for pid in $pids; do
|
||||
echo -e "${YELLOW} Killing job with PID $pid${NC}"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Wait a moment for ports to be released
|
||||
sleep 1
|
||||
|
||||
echo -e "${GREEN} Cleanup complete${NC}\n"
|
||||
|
||||
# Step 2: Check if any of the ports are already in use
|
||||
echo -e "${YELLOW}Step 2: Checking if ports are available...${NC}"
|
||||
|
||||
ports_in_use=()
|
||||
|
||||
for pf in "${PORT_FORWARDS[@]}"; do
|
||||
IFS=':' read -r local_port namespace service remote_port description <<< "$pf"
|
||||
|
||||
# Check if port is in use using lsof or netstat
|
||||
if command -v lsof &> /dev/null; then
|
||||
if lsof -Pi :$local_port -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
ports_in_use+=("$local_port:$description")
|
||||
fi
|
||||
elif command -v netstat &> /dev/null; then
|
||||
if netstat -tuln | grep -q ":$local_port "; then
|
||||
ports_in_use+=("$local_port:$description")
|
||||
fi
|
||||
elif command -v ss &> /dev/null; then
|
||||
if ss -tuln | grep -q ":$local_port "; then
|
||||
ports_in_use+=("$local_port:$description")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If any ports are in use, report and exit
|
||||
if [ ${#ports_in_use[@]} -gt 0 ]; then
|
||||
echo -e "${RED}ERROR: The following ports are already in use:${NC}"
|
||||
for port_info in "${ports_in_use[@]}"; do
|
||||
IFS=':' read -r port desc <<< "$port_info"
|
||||
echo -e "${RED} - Port $port (for $desc)${NC}"
|
||||
done
|
||||
echo -e "\n${RED}Please free these ports before running this script.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN} All ports are available${NC}\n"
|
||||
|
||||
# Step 3: Create all port forwards
|
||||
echo -e "${YELLOW}Step 3: Creating port forwards...${NC}"
|
||||
|
||||
for pf in "${PORT_FORWARDS[@]}"; do
|
||||
IFS=':' read -r local_port namespace service remote_port description <<< "$pf"
|
||||
|
||||
echo -e "${GREEN} Starting port-forward: $description${NC}"
|
||||
echo -e " Local port: $local_port -> $namespace/$service:$remote_port"
|
||||
|
||||
kubectl -n "$namespace" port-forward "svc/$service" "$local_port:$remote_port" &
|
||||
|
||||
# Give it a moment to start
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo -e "\n${GREEN}=== All port forwards created successfully ===${NC}"
|
||||
echo -e "\n${YELLOW}Active port forwards:${NC}"
|
||||
for pf in "${PORT_FORWARDS[@]}"; do
|
||||
IFS=':' read -r local_port namespace service remote_port description <<< "$pf"
|
||||
echo -e " - ${GREEN}localhost:$local_port${NC} -> $description ($namespace/$service)"
|
||||
done
|
||||
|
||||
echo -e "\n${YELLOW}To stop all port forwards, run:${NC}"
|
||||
echo -e " jobs -p | xargs kill"
|
||||
echo -e "\n${YELLOW}To view active port forwards:${NC}"
|
||||
echo -e " jobs -l"
|
||||
@@ -4,7 +4,6 @@ from pytest import mark
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||
from model_manager.activities.gates import Gates
|
||||
from model_manager.activities.mlflow import MLFlow
|
||||
from model_manager.activities.training import Training
|
||||
|
||||
@@ -12,11 +11,9 @@ from model_manager.activities.training import Training
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Gates.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___init__(
|
||||
mock_training_init,
|
||||
mock_gates_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
@@ -59,7 +56,6 @@ def test___init__(
|
||||
assert isinstance(activities, Activities)
|
||||
assert isinstance(activities, ExperimentTracking)
|
||||
assert isinstance(activities, MLFlow)
|
||||
assert isinstance(activities, Gates)
|
||||
assert isinstance(activities, Training)
|
||||
|
||||
mock_experiment_tracking_init.assert_called_once_with(
|
||||
@@ -100,10 +96,6 @@ def test___init__(
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
mock_gates_init.assert_called_once_with(
|
||||
ANY, logger=logger, notification_handler=notification_handler
|
||||
)
|
||||
|
||||
mock_training_init.assert_called_once_with(
|
||||
ANY, logger=logger, notification_handler=notification_handler
|
||||
)
|
||||
@@ -150,3 +142,394 @@ async def test_shutdown(_mock_mlflow_init, mock_experiment_tracking_init):
|
||||
|
||||
await activities.shutdown()
|
||||
mock_experiment_tracking_init.close.assert_called_once()
|
||||
|
||||
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___del___with_engine(
|
||||
mock_training_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
):
|
||||
"""Test __del__ calls parent destructor when engine attribute exists."""
|
||||
postgres_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'user': 'postgres',
|
||||
'password': 'postgres',
|
||||
'dbname': 'postgres',
|
||||
'min_connections': 1,
|
||||
'max_connections': 10,
|
||||
}
|
||||
|
||||
mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'}
|
||||
|
||||
minio_config = {
|
||||
'endpoint_url': 'http://localhost:9000',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'region': 'us-east-1',
|
||||
'use_ssl': False,
|
||||
'max_retry_attempts': 3,
|
||||
'retry_mode': 'adaptive',
|
||||
'connect_timeout': 10,
|
||||
'read_timeout': 60,
|
||||
}
|
||||
|
||||
logger = MagicMock()
|
||||
notification_handler = MagicMock()
|
||||
|
||||
activities = Activities(
|
||||
postgres_config=postgres_config,
|
||||
mlflow_config=mlflow_config,
|
||||
minio_config=minio_config,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Add engine attribute to simulate Postgres initialization
|
||||
activities.engine = MagicMock()
|
||||
|
||||
# Create a mock __del__ that will be detected by hasattr
|
||||
mock_parent_del = MagicMock()
|
||||
|
||||
# Patch both the class and the instance to ensure super().__del__ exists and is callable
|
||||
with patch.object(ExperimentTracking, '__del__', mock_parent_del, create=True):
|
||||
# Trigger __del__
|
||||
activities.__del__()
|
||||
|
||||
# Verify parent __del__ was called
|
||||
mock_parent_del.assert_called_once()
|
||||
|
||||
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___del___without_engine(
|
||||
mock_training_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
):
|
||||
"""Test __del__ does not call parent destructor when engine attribute is missing."""
|
||||
postgres_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'user': 'postgres',
|
||||
'password': 'postgres',
|
||||
'dbname': 'postgres',
|
||||
'min_connections': 1,
|
||||
'max_connections': 10,
|
||||
}
|
||||
|
||||
mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'}
|
||||
|
||||
minio_config = {
|
||||
'endpoint_url': 'http://localhost:9000',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'region': 'us-east-1',
|
||||
'use_ssl': False,
|
||||
'max_retry_attempts': 3,
|
||||
'retry_mode': 'adaptive',
|
||||
'connect_timeout': 10,
|
||||
'read_timeout': 60,
|
||||
}
|
||||
|
||||
logger = MagicMock()
|
||||
notification_handler = MagicMock()
|
||||
|
||||
activities = Activities(
|
||||
postgres_config=postgres_config,
|
||||
mlflow_config=mlflow_config,
|
||||
minio_config=minio_config,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Ensure engine attribute does NOT exist
|
||||
if hasattr(activities, 'engine'):
|
||||
delattr(activities, 'engine')
|
||||
|
||||
# Mock super().__del__ to track if it's called
|
||||
with patch.object(ExperimentTracking, '__del__', MagicMock()) as mock_parent_del:
|
||||
# Trigger __del__
|
||||
activities.__del__()
|
||||
|
||||
# Verify parent __del__ was NOT called
|
||||
mock_parent_del.assert_not_called()
|
||||
|
||||
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___del___handles_exception_gracefully(
|
||||
mock_training_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
):
|
||||
"""Test __del__ handles exceptions from parent destructor gracefully."""
|
||||
postgres_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'user': 'postgres',
|
||||
'password': 'postgres',
|
||||
'dbname': 'postgres',
|
||||
'min_connections': 1,
|
||||
'max_connections': 10,
|
||||
}
|
||||
|
||||
mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'}
|
||||
|
||||
minio_config = {
|
||||
'endpoint_url': 'http://localhost:9000',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'region': 'us-east-1',
|
||||
'use_ssl': False,
|
||||
'max_retry_attempts': 3,
|
||||
'retry_mode': 'adaptive',
|
||||
'connect_timeout': 10,
|
||||
'read_timeout': 60,
|
||||
}
|
||||
|
||||
logger = MagicMock()
|
||||
notification_handler = MagicMock()
|
||||
|
||||
activities = Activities(
|
||||
postgres_config=postgres_config,
|
||||
mlflow_config=mlflow_config,
|
||||
minio_config=minio_config,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Add engine attribute
|
||||
activities.engine = MagicMock()
|
||||
|
||||
# Mock super().__del__ to raise an exception
|
||||
mock_parent_del = MagicMock(side_effect=RuntimeError('Cleanup failed'))
|
||||
|
||||
with patch.object(ExperimentTracking, '__del__', mock_parent_del):
|
||||
# Trigger __del__ - should not raise exception
|
||||
try:
|
||||
activities.__del__()
|
||||
# Test passes if no exception is raised
|
||||
except Exception as e:
|
||||
# Test fails if exception propagates
|
||||
raise AssertionError(f'__del__ should not raise exception, but raised: {e}') from e
|
||||
|
||||
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___del___when_parent_has_no_del(
|
||||
mock_training_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
):
|
||||
"""Test __del__ handles case when parent class has no __del__ method."""
|
||||
postgres_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'user': 'postgres',
|
||||
'password': 'postgres',
|
||||
'dbname': 'postgres',
|
||||
'min_connections': 1,
|
||||
'max_connections': 10,
|
||||
}
|
||||
|
||||
mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'}
|
||||
|
||||
minio_config = {
|
||||
'endpoint_url': 'http://localhost:9000',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'region': 'us-east-1',
|
||||
'use_ssl': False,
|
||||
'max_retry_attempts': 3,
|
||||
'retry_mode': 'adaptive',
|
||||
'connect_timeout': 10,
|
||||
'read_timeout': 60,
|
||||
}
|
||||
|
||||
logger = MagicMock()
|
||||
notification_handler = MagicMock()
|
||||
|
||||
activities = Activities(
|
||||
postgres_config=postgres_config,
|
||||
mlflow_config=mlflow_config,
|
||||
minio_config=minio_config,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Add engine attribute
|
||||
activities.engine = MagicMock()
|
||||
|
||||
# Remove __del__ from parent to simulate it not existing
|
||||
with patch.object(ExperimentTracking, '__del__', create=False):
|
||||
# Trigger __del__ - should not raise exception
|
||||
try:
|
||||
activities.__del__()
|
||||
# Test passes if no exception is raised
|
||||
except Exception as e:
|
||||
# Test fails if exception propagates
|
||||
raise AssertionError(
|
||||
f'__del__ should handle missing parent __del__, but raised: {e}'
|
||||
) from e
|
||||
|
||||
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___del___calls_super_successfully(
|
||||
mock_training_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
):
|
||||
"""Test __del__ successfully calls super().__del__() when it exists - covers line 118."""
|
||||
postgres_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'user': 'postgres',
|
||||
'password': 'postgres',
|
||||
'dbname': 'postgres',
|
||||
'min_connections': 1,
|
||||
'max_connections': 10,
|
||||
}
|
||||
|
||||
mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'}
|
||||
|
||||
minio_config = {
|
||||
'endpoint_url': 'http://localhost:9000',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'region': 'us-east-1',
|
||||
'use_ssl': False,
|
||||
'max_retry_attempts': 3,
|
||||
'retry_mode': 'adaptive',
|
||||
'connect_timeout': 10,
|
||||
'read_timeout': 60,
|
||||
}
|
||||
|
||||
logger = MagicMock()
|
||||
notification_handler = MagicMock()
|
||||
|
||||
# Mock all parent __init__ methods to return None
|
||||
mock_experiment_tracking_init.return_value = None
|
||||
mock_mlflow_init.return_value = None
|
||||
mock_minio_init.return_value = None
|
||||
mock_training_init.return_value = None
|
||||
|
||||
activities = Activities(
|
||||
postgres_config=postgres_config,
|
||||
mlflow_config=mlflow_config,
|
||||
minio_config=minio_config,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Add engine attribute to simulate Postgres initialization
|
||||
activities.engine = MagicMock()
|
||||
|
||||
# Track if super().__del__() was actually called
|
||||
super_del_called = []
|
||||
|
||||
def mock_super_del(self):
|
||||
"""Mock parent __del__ that tracks when it's called."""
|
||||
super_del_called.append(True)
|
||||
|
||||
# Patch ExperimentTracking.__del__ to exist and be callable
|
||||
with patch.object(ExperimentTracking, '__del__', mock_super_del, create=True):
|
||||
# Trigger __del__ - this should execute line 118: super().__del__()
|
||||
activities.__del__()
|
||||
|
||||
# Verify that super().__del__() was actually called (line 118 executed)
|
||||
assert len(super_del_called) == 1, 'super().__del__() should have been called once'
|
||||
|
||||
|
||||
@patch('model_manager.activities.activities.ExperimentTracking.__init__')
|
||||
@patch('model_manager.activities.activities.MLFlow.__init__')
|
||||
@patch('model_manager.activities.activities.MinIO.__init__')
|
||||
@patch('model_manager.activities.activities.Training.__init__')
|
||||
def test___del___when_super_has_no_del_method(
|
||||
mock_training_init,
|
||||
mock_minio_init,
|
||||
mock_mlflow_init,
|
||||
mock_experiment_tracking_init,
|
||||
):
|
||||
"""Test __del__ handles case when hasattr(super(), '__del__') returns False - covers line 118 false branch."""
|
||||
postgres_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'user': 'postgres',
|
||||
'password': 'postgres',
|
||||
'dbname': 'postgres',
|
||||
'min_connections': 1,
|
||||
'max_connections': 10,
|
||||
}
|
||||
|
||||
mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'}
|
||||
|
||||
minio_config = {
|
||||
'endpoint_url': 'http://localhost:9000',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'region': 'us-east-1',
|
||||
'use_ssl': False,
|
||||
'max_retry_attempts': 3,
|
||||
'retry_mode': 'adaptive',
|
||||
'connect_timeout': 10,
|
||||
'read_timeout': 60,
|
||||
}
|
||||
|
||||
logger = MagicMock()
|
||||
notification_handler = MagicMock()
|
||||
|
||||
# Mock all __init__ methods to return None
|
||||
mock_experiment_tracking_init.return_value = None
|
||||
mock_mlflow_init.return_value = None
|
||||
mock_minio_init.return_value = None
|
||||
mock_training_init.return_value = None
|
||||
|
||||
activities = Activities(
|
||||
postgres_config=postgres_config,
|
||||
mlflow_config=mlflow_config,
|
||||
minio_config=minio_config,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Add engine attribute to pass the first hasattr check (line 115)
|
||||
activities.engine = MagicMock()
|
||||
|
||||
# Create a mock class without __del__ method to simulate super() not having __del__
|
||||
class MockSuperWithoutDel:
|
||||
"""Mock class that explicitly does not have __del__ method."""
|
||||
|
||||
pass
|
||||
|
||||
# Patch super() to return an instance that doesn't have __del__
|
||||
mock_super_instance = MockSuperWithoutDel()
|
||||
|
||||
with patch('builtins.super', return_value=mock_super_instance):
|
||||
# Trigger __del__ - should handle the case when hasattr(super(), '__del__') is False
|
||||
try:
|
||||
activities.__del__()
|
||||
# Test passes - the false branch of line 118 was executed without error
|
||||
except Exception as e:
|
||||
# Test fails if exception propagates
|
||||
raise AssertionError(
|
||||
f'__del__ should handle super() without __del__ method, but raised: {e}'
|
||||
) from e
|
||||
|
||||
@@ -383,3 +383,182 @@ def test_update_type_enum_values():
|
||||
assert UpdateType.STATUS == 'status'
|
||||
assert UpdateType.STATUS_WITH_ERROR == 'status_with_error'
|
||||
assert UpdateType.MODEL_SAVED == 'model_saved'
|
||||
|
||||
|
||||
# Tests for __del__ method - 100% coverage
|
||||
|
||||
|
||||
@patch('model_manager.activities.experiment_tracking.Postgres.__init__')
|
||||
def test___del___with_engine_and_parent_del_exists(mock_postgres_init):
|
||||
"""Test __del__ calls parent destructor when engine exists and parent has __del__."""
|
||||
mock_postgres_init.return_value = None
|
||||
|
||||
tracking = ExperimentTracking(
|
||||
host='localhost',
|
||||
port=5432,
|
||||
user='test',
|
||||
password='test',
|
||||
dbname='test',
|
||||
min_connections=1,
|
||||
max_connections=10,
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
)
|
||||
|
||||
# Add engine attribute to simulate Postgres initialization
|
||||
tracking.engine = MagicMock()
|
||||
|
||||
# Track if parent __del__ was called
|
||||
parent_del_called = []
|
||||
|
||||
def mock_parent_del(self):
|
||||
"""Mock parent __del__ that tracks when it's called."""
|
||||
parent_del_called.append(True)
|
||||
|
||||
# Patch parent class to have __del__ method
|
||||
with patch.object(type(tracking).__bases__[0], '__del__', mock_parent_del, create=True):
|
||||
# Trigger __del__ - should call parent __del__ (line 103)
|
||||
tracking.__del__()
|
||||
|
||||
# Verify parent __del__ was called (line 103 executed)
|
||||
assert len(parent_del_called) == 1, 'Parent __del__ should have been called once'
|
||||
|
||||
|
||||
@patch('model_manager.activities.experiment_tracking.Postgres.__init__')
|
||||
def test___del___without_engine(mock_postgres_init):
|
||||
"""Test __del__ does not call parent destructor when engine attribute is missing."""
|
||||
mock_postgres_init.return_value = None
|
||||
|
||||
tracking = ExperimentTracking(
|
||||
host='localhost',
|
||||
port=5432,
|
||||
user='test',
|
||||
password='test',
|
||||
dbname='test',
|
||||
min_connections=1,
|
||||
max_connections=10,
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
)
|
||||
|
||||
# Ensure engine attribute does not exist
|
||||
if hasattr(tracking, 'engine'):
|
||||
delattr(tracking, 'engine')
|
||||
|
||||
# Mock parent __del__ to track if it's called
|
||||
mock_parent_del = MagicMock()
|
||||
|
||||
with patch.object(type(tracking).__bases__[0], '__del__', mock_parent_del, create=True):
|
||||
# Trigger __del__ - should NOT call parent __del__ (line 100 is False)
|
||||
tracking.__del__()
|
||||
|
||||
# Verify parent __del__ was NOT called
|
||||
mock_parent_del.assert_not_called()
|
||||
|
||||
|
||||
@patch('model_manager.activities.experiment_tracking.Postgres.__init__')
|
||||
def test___del___when_parent_has_no_del_method(mock_postgres_init):
|
||||
"""Test __del__ handles case when parent class has no __del__ method - covers line 102 false branch."""
|
||||
mock_postgres_init.return_value = None
|
||||
|
||||
tracking = ExperimentTracking(
|
||||
host='localhost',
|
||||
port=5432,
|
||||
user='test',
|
||||
password='test',
|
||||
dbname='test',
|
||||
min_connections=1,
|
||||
max_connections=10,
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
)
|
||||
|
||||
# Add engine attribute to pass the first hasattr check (line 100)
|
||||
tracking.engine = MagicMock()
|
||||
|
||||
# Create a mock class without __del__ method
|
||||
class MockSuperWithoutDel:
|
||||
"""Mock class that explicitly does not have __del__ method."""
|
||||
|
||||
pass
|
||||
|
||||
# Patch super() to return an instance without __del__
|
||||
mock_super_instance = MockSuperWithoutDel()
|
||||
|
||||
with patch('builtins.super', return_value=mock_super_instance):
|
||||
# Trigger __del__ - should handle the case when hasattr(super(), '__del__') is False (line 102)
|
||||
try:
|
||||
tracking.__del__()
|
||||
# Test passes - the false branch of line 102 was executed without error
|
||||
except Exception as e:
|
||||
raise AssertionError(
|
||||
f'__del__ should handle super() without __del__ method, but raised: {e}'
|
||||
) from e
|
||||
|
||||
|
||||
@patch('model_manager.activities.experiment_tracking.Postgres.__init__')
|
||||
def test___del___handles_exception_from_parent_del(mock_postgres_init):
|
||||
"""Test __del__ handles exceptions from parent destructor gracefully - covers line 104."""
|
||||
mock_postgres_init.return_value = None
|
||||
|
||||
tracking = ExperimentTracking(
|
||||
host='localhost',
|
||||
port=5432,
|
||||
user='test',
|
||||
password='test',
|
||||
dbname='test',
|
||||
min_connections=1,
|
||||
max_connections=10,
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
)
|
||||
|
||||
# Add engine attribute
|
||||
tracking.engine = MagicMock()
|
||||
|
||||
# Mock parent __del__ to raise an exception
|
||||
mock_parent_del = MagicMock(side_effect=RuntimeError('Cleanup failed'))
|
||||
|
||||
with patch.object(type(tracking).__bases__[0], '__del__', mock_parent_del, create=True):
|
||||
# Trigger __del__ - should catch exception and not propagate it (line 104-106)
|
||||
try:
|
||||
tracking.__del__()
|
||||
# Test passes if no exception is raised
|
||||
except Exception as e:
|
||||
raise AssertionError(
|
||||
f'__del__ should handle exceptions gracefully, but raised: {e}'
|
||||
) from e
|
||||
|
||||
|
||||
@patch('model_manager.activities.experiment_tracking.Postgres.__init__')
|
||||
def test___del___handles_attribute_error_from_parent_del(mock_postgres_init):
|
||||
"""Test __del__ handles AttributeError from parent destructor - covers line 104 exception handling."""
|
||||
mock_postgres_init.return_value = None
|
||||
|
||||
tracking = ExperimentTracking(
|
||||
host='localhost',
|
||||
port=5432,
|
||||
user='test',
|
||||
password='test',
|
||||
dbname='test',
|
||||
min_connections=1,
|
||||
max_connections=10,
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
)
|
||||
|
||||
# Add engine attribute
|
||||
tracking.engine = MagicMock()
|
||||
|
||||
# Mock parent __del__ to raise AttributeError
|
||||
mock_parent_del = MagicMock(side_effect=AttributeError('engine not found'))
|
||||
|
||||
with patch.object(type(tracking).__bases__[0], '__del__', mock_parent_del, create=True):
|
||||
# Trigger __del__ - should catch AttributeError and not propagate it
|
||||
try:
|
||||
tracking.__del__()
|
||||
# Test passes if no exception is raised
|
||||
except Exception as e:
|
||||
raise AssertionError(
|
||||
f'__del__ should handle AttributeError gracefully, but raised: {e}'
|
||||
) from e
|
||||
|
||||
@@ -1,630 +0,0 @@
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from pytest import fixture, mark
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
|
||||
from model_manager.activities.gates import Gates
|
||||
|
||||
|
||||
@fixture
|
||||
def gates_activity():
|
||||
gates = Gates(
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
)
|
||||
gates.error = MagicMock()
|
||||
gates.debug = MagicMock()
|
||||
gates.info = MagicMock()
|
||||
gates.warning = MagicMock()
|
||||
gates.critical = MagicMock()
|
||||
gates.send_notification = MagicMock()
|
||||
return gates
|
||||
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'model_id': 'test_model',
|
||||
'model_name': 'test_model',
|
||||
'workflow_name': 'test_workflow',
|
||||
'schema_name': 'test_schedule',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_input_gate_invalid_filter(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}},
|
||||
'data': {'value': [1, 2, 3]},
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.input_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.error.assert_called_once_with(
|
||||
'Filter INVALID_FILTER not found', metadata['metadata']
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.activities.gates.input_filter_functions')
|
||||
async def test_input_gate_filter_exception(mock_input_filter_functions, gates_activity):
|
||||
# Arrange
|
||||
mock_input_filter_functions.__contains__.return_value = True
|
||||
mock_input_filter_functions.__getitem__.return_value = MagicMock(
|
||||
side_effect=Exception('Test error')
|
||||
)
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'EMPTY_DATA': {'policy': 'STOP', 'config': {}}},
|
||||
'data': {'value': []},
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.input_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.send_notification.assert_called_once_with(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='INTPUT_GATE_ERROR__EMPTY_DATA',
|
||||
message="Error in filter EMPTY_DATA:{'policy': 'STOP', 'config': {}}: \n Test error",
|
||||
block='input_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=ANY,
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_input_gate_no_filters(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {},
|
||||
'data': {'value': [1, 2, 3]},
|
||||
'path_priority': ['CONTINUE', 'STOP', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.input_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_input_gate_with_filter(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'EMPTY_DATA': {'policy': 'STOP', 'config': {}}},
|
||||
'data': {'value': []},
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.input_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == ('STOP', -1, 'Input data with bad quality')
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_input_gate_filter_returns_false(gates_activity):
|
||||
"""Test to cover line 129 branch when filter returns False (filter passes)."""
|
||||
# Arrange - Use data that will NOT trigger EMPTY_DATA filter (has data)
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'EMPTY_DATA': {'policy': 'STOP', 'config': {}}},
|
||||
'data': {'value': [1, 2, 3, 4, 5]}, # Has data, filter returns False
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.input_gate(input_data)
|
||||
|
||||
# Assert - Filter returns False, so no policy is added to filter_output
|
||||
assert result == (None, 0, '') # No filter triggered
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_response_gate_invalid_filter(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}},
|
||||
'data': {'content': {'message': 'success'}},
|
||||
'type': 'test',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_response_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.activities.gates.mlflow_response_filter_functions')
|
||||
async def test_mlflow_response_gate_filter_exception(
|
||||
mock_mlflow_response_filter_functions, gates_activity
|
||||
):
|
||||
# Arrange
|
||||
mock_mlflow_response_filter_functions.__contains__.return_value = True
|
||||
mock_mlflow_response_filter_functions.__getitem__.return_value = MagicMock(
|
||||
side_effect=Exception('Test error')
|
||||
)
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}},
|
||||
'data': {'content': {'message': 'success'}},
|
||||
'type': 'test',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_response_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.send_notification.assert_called_once_with(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='MLFLOW_GATE_RESPONSE_FILTER__INVALID_FILTER',
|
||||
message="Error in filter INVALID_FILTER:{'POLICY': 'STOP'}: \n Test error",
|
||||
block='mlflow_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=ANY,
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_response_gate_no_filters(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {},
|
||||
'data': {'content': {'message': 'success'}},
|
||||
'type': 'test',
|
||||
'path_priority': ['CONTINUE', 'STOP', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_response_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_response_gate_with_filter(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'API_ERROR': {'policy': 'STOP'}},
|
||||
'data': {
|
||||
'success': False,
|
||||
'content': {'message': 'API error occurred', 'traceback': 'error trace'},
|
||||
},
|
||||
'type': 'test',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_response_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == ('STOP', -1, 'API error occurred')
|
||||
gates_activity.debug.assert_called()
|
||||
gates_activity.send_notification.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_response_gate_filter_returns_false(gates_activity):
|
||||
"""Test to cover line 208 branch when filter returns False (no API error)."""
|
||||
# Arrange - Use data that will NOT trigger API_ERROR filter (success=True)
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'API_ERROR': {'policy': 'STOP'}},
|
||||
'data': {
|
||||
'success': True, # Success=True, filter returns False
|
||||
'content': {'message': 'Operation successful', 'result': 'data'},
|
||||
},
|
||||
'type': 'transform',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_response_gate(input_data)
|
||||
|
||||
# Assert - Filter returns False, so no policy is added to filter_output
|
||||
assert result == (None, 0, '') # No filter triggered
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_content_gate_invalid_filter(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}},
|
||||
'data': {'value': [1, 2, 3]},
|
||||
'type': 'test',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_content_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.activities.gates.mlflow_content_filter_functions')
|
||||
async def test_mlflow_content_gate_filter_exception(
|
||||
mock_mlflow_content_filter_functions, gates_activity
|
||||
):
|
||||
# Arrange
|
||||
mock_mlflow_content_filter_functions.__contains__.return_value = True
|
||||
mock_mlflow_content_filter_functions.__getitem__.return_value = MagicMock(
|
||||
side_effect=Exception('Test error')
|
||||
)
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'API_ERROR': {'POLICY': 'STOP'}},
|
||||
'data': {
|
||||
'success': False,
|
||||
'content': {'message': 'API error occurred', 'traceback': 'error trace'},
|
||||
},
|
||||
'type': 'test',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_content_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.debug.assert_called()
|
||||
gates_activity.send_notification.assert_called_once_with(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='MLFLOW_GATE_CONTENT_FILTER__API_ERROR',
|
||||
message="Error in filter API_ERROR:{'POLICY': 'STOP'}: \n Test error",
|
||||
block='mlflow_gate',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=ANY,
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_content_gate_no_filters(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {},
|
||||
'data': {'value': [1, 2, 3]},
|
||||
'type': 'test',
|
||||
'path_priority': ['CONTINUE', 'STOP', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_content_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == (None, 0, '')
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_content_gate_with_filter(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'NAN_VALUES': {'policy': 'STOP', 'config': {}}},
|
||||
'data': {'value': [None, None, None]},
|
||||
'type': 'test',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_content_gate(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == ('STOP', -1, 'Transformed data not passed the content filter')
|
||||
gates_activity.debug.assert_called()
|
||||
gates_activity.send_notification.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_mlflow_content_gate_filter_returns_false(gates_activity):
|
||||
"""Test to cover line 293 branch when filter returns False (no NaN values)."""
|
||||
# Arrange - Use data that will NOT trigger NAN_VALUES filter (no NaN)
|
||||
input_data = {
|
||||
**metadata,
|
||||
'filters': {'NAN_VALUES': {'policy': 'STOP', 'config': {}}},
|
||||
'data': {'value': [1.0, 2.0, 3.0, 4.0, 5.0]}, # All valid numbers, no NaN
|
||||
'type': 'predict',
|
||||
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.mlflow_content_gate(input_data)
|
||||
|
||||
# Assert - Filter returns False, so no policy is added to filter_output
|
||||
assert result == (None, 0, '') # No filter triggered
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
def test_get_prediction_store_policy_invalid_policy(gates_activity):
|
||||
# Arrange
|
||||
prediction_store_policy = 'INVALID_POLICY'
|
||||
|
||||
# Act
|
||||
policy_type, policy_value = gates_activity.get_prediction_store_policy(
|
||||
prediction_store_policy, metadata
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert policy_type == 'lts'
|
||||
assert policy_value == 1
|
||||
|
||||
|
||||
def test_get_prediction_store_policy_invalid_policy_value(gates_activity):
|
||||
# Arrange
|
||||
prediction_store_policy = 'abc:INVALID_VALUE'
|
||||
|
||||
# Act
|
||||
policy_type, policy_value = gates_activity.get_prediction_store_policy(
|
||||
prediction_store_policy, metadata
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert policy_type == 'lts'
|
||||
assert policy_value == 1
|
||||
|
||||
|
||||
def test_get_prediction_store_policy_valid_policy_type(gates_activity):
|
||||
# Arrange
|
||||
prediction_store_policy = 'abc:1'
|
||||
|
||||
# Act
|
||||
policy_type, policy_value = gates_activity.get_prediction_store_policy(
|
||||
prediction_store_policy, metadata
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert policy_type == 'lts'
|
||||
assert policy_value == 1
|
||||
|
||||
|
||||
def test_get_prediction_store_policy_valid_policy(gates_activity):
|
||||
# Arrange
|
||||
prediction_store_policy = 'erl:1'
|
||||
|
||||
# Act
|
||||
policy_type, policy_value = gates_activity.get_prediction_store_policy(
|
||||
prediction_store_policy, metadata
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert policy_type == 'erl'
|
||||
assert policy_value == 1
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_format_prediction_no_timestamp(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'data': {
|
||||
'prediction': {'2023-05-26 11:12:27': 1},
|
||||
'response_time': {'2023-05-26 11:12:27': 0.1},
|
||||
},
|
||||
'model_id': 'test_model',
|
||||
'prediction_confidence': 0.9,
|
||||
'prediction_store_policy': 'lts:1',
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.format_prediction(input_data)
|
||||
|
||||
# Assert
|
||||
assert result['prediction'] == {0: 1}
|
||||
assert result['response_time'] == {0: ANY}
|
||||
assert result['timestamp'] == {0: '2023-05-26 11:12:27'}
|
||||
assert result['model_id'] == {0: 'test_model'}
|
||||
assert result['prediction_confidence'] == {0: 0.9}
|
||||
assert result['prediction_status'] == {0: 'Good'}
|
||||
assert result['comments'] == {0: ''}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_format_prediction_with_timestamp_erl(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'data': {
|
||||
'prediction': {
|
||||
'2023-05-26 11:12:27': 1,
|
||||
'2023-05-26 11:12:28': 2,
|
||||
'2023-05-26 11:12:29': 3,
|
||||
},
|
||||
'response_time': {
|
||||
'2023-05-26 11:12:27': 0.1,
|
||||
'2023-05-26 11:12:28': 0.2,
|
||||
'2023-05-26 11:12:29': 0.3,
|
||||
},
|
||||
},
|
||||
'model_id': 'test_model',
|
||||
'prediction_confidence': 0.9,
|
||||
'prediction_store_policy': 'erl:2',
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.format_prediction(input_data)
|
||||
|
||||
# Assert
|
||||
assert result['prediction'] == {0: 2, 1: 1}
|
||||
assert result['response_time'] == {0: 0.2, 1: 0.1}
|
||||
assert result['timestamp'] == {0: '2023-05-26 11:12:28', 1: '2023-05-26 11:12:27'}
|
||||
assert result['model_id'] == {0: 'test_model', 1: 'test_model'}
|
||||
assert result['prediction_confidence'] == {0: 0.9, 1: 0.9}
|
||||
assert result['prediction_status'] == {0: 'Good', 1: 'Good'}
|
||||
assert result['comments'] == {0: '', 1: ''}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_format_prediction_with_timestamp_lts(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'data': {
|
||||
'prediction': {
|
||||
'2023-05-26 11:12:27': 1,
|
||||
'2023-05-26 11:12:28': 2,
|
||||
'2023-05-26 11:12:29': 3,
|
||||
},
|
||||
'response_time': {
|
||||
'2023-05-26 11:12:27': 0.1,
|
||||
'2023-05-26 11:12:28': 0.2,
|
||||
'2023-05-26 11:12:29': 0.3,
|
||||
},
|
||||
},
|
||||
'model_id': 'test_model',
|
||||
'prediction_confidence': 0.9,
|
||||
'prediction_store_policy': 'lts:2',
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.format_prediction(input_data)
|
||||
|
||||
# Assert
|
||||
assert result['prediction'] == {0: 3, 1: 2}
|
||||
assert result['response_time'] == {0: 0.3, 1: 0.2}
|
||||
assert result['timestamp'] == {0: '2023-05-26 11:12:29', 1: '2023-05-26 11:12:28'}
|
||||
assert result['model_id'] == {0: 'test_model', 1: 'test_model'}
|
||||
assert result['prediction_confidence'] == {0: 0.9, 1: 0.9}
|
||||
assert result['prediction_status'] == {0: 'Good', 1: 'Good'}
|
||||
assert result['comments'] == {0: '', 1: ''}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_format_prediction_with_timestamp_invalid_policy(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'data': {
|
||||
'prediction': [1, 2, 3],
|
||||
'response_time': [0.1, 0.2, 0.3],
|
||||
'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28', '2023-05-26 11:12:29'],
|
||||
},
|
||||
'model_id': 'test_model',
|
||||
'prediction_confidence': 0.9,
|
||||
'prediction_store_policy': 'lts:2',
|
||||
}
|
||||
gates_activity.get_prediction_store_policy = MagicMock(return_value=('invalid', 1))
|
||||
|
||||
try:
|
||||
await gates_activity.format_prediction(input_data)
|
||||
except ValueError as e:
|
||||
assert str(e) == 'Invalid policy type: invalid'
|
||||
else:
|
||||
raise AssertionError('Expected ValueError')
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_format_default_prediction(gates_activity):
|
||||
# Arrange
|
||||
input_data = {
|
||||
**metadata,
|
||||
'timestamp': '2023-05-26 11:12:27',
|
||||
'model_id': 'test_model',
|
||||
'prediction_confidence': 0.1,
|
||||
'comment': 'Test comment',
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.format_default_prediction(input_data)
|
||||
|
||||
# Assert
|
||||
assert result['prediction'] == {0: 0}
|
||||
assert result['response_time'] == {0: 0}
|
||||
assert result['timestamp'] == {0: '2023-05-26 11:12:27'}
|
||||
assert result['model_id'] == {0: 'test_model'}
|
||||
assert result['prediction_confidence'] == {0: 0.1}
|
||||
assert result['prediction_status'] == {0: 'Bad'}
|
||||
assert result['comments'] == {0: 'Test comment'}
|
||||
gates_activity.debug.assert_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_get_last_timestamp_with_data(gates_activity):
|
||||
# Arrange
|
||||
input_data = {**metadata, 'data': {'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28']}}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.get_last_timestamp(input_data)
|
||||
|
||||
# Assert
|
||||
assert result == '2023-05-26 11:12:28'
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_get_last_timestamp_no_data(gates_activity):
|
||||
# Arrange
|
||||
input_data = {'data': {}, **metadata}
|
||||
|
||||
# Act
|
||||
result = await gates_activity.get_last_timestamp(input_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, str) # Should be a timestamp string
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.activities.gates.metrics')
|
||||
async def test_write_metrics(mock_metrics, gates_activity):
|
||||
"""Test write_metrics method."""
|
||||
input_data = {
|
||||
**metadata,
|
||||
'prediction': {
|
||||
'prediction': [1, 2, 3],
|
||||
'prediction_confidence': [0.9, 0.8, 0.7],
|
||||
'response_time': [0.1, 0.2, 0.3],
|
||||
},
|
||||
}
|
||||
await gates_activity.write_metrics(input_data)
|
||||
mock_metrics.PREDICTIONS_WRITTEN_COUNT.labels.assert_called_once_with(
|
||||
pod_id=gates_activity.pod_id,
|
||||
model_name=metadata['metadata']['model_name'],
|
||||
pipeline_name=metadata['metadata']['workflow_name'],
|
||||
)
|
||||
mock_metrics.PREDICTIONS_WRITTEN_COUNT.labels.return_value.inc.assert_called_once_with()
|
||||
|
||||
mock_metrics.PREDICTION_CONFIDENCE_MONITOR.labels.assert_called_once_with(
|
||||
pod_id=gates_activity.pod_id,
|
||||
model_name=metadata['metadata']['model_name'],
|
||||
pipeline_name=metadata['metadata']['workflow_name'],
|
||||
)
|
||||
mock_metrics.PREDICTION_CONFIDENCE_MONITOR.labels.return_value.set.assert_called_once_with(0.9)
|
||||
|
||||
mock_metrics.PREDICTION_RESPONSE_TIME_MONITOR.labels.assert_called_once_with(
|
||||
pod_id=gates_activity.pod_id,
|
||||
model_name=metadata['metadata']['model_name'],
|
||||
pipeline_name=metadata['metadata']['workflow_name'],
|
||||
)
|
||||
mock_metrics.PREDICTION_RESPONSE_TIME_MONITOR.labels.return_value.observe.assert_called_once_with(
|
||||
0.1
|
||||
)
|
||||
@@ -1,10 +1,8 @@
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pytest import fixture, mark
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
from sientia_do.temporal.constants import DATETIME_FORMAT, DATETIME_FORMAT_WITH_TZ
|
||||
|
||||
from model_manager.activities.mlflow import MLFlow
|
||||
|
||||
@@ -55,253 +53,6 @@ metadata = {
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.activities.mlflow.DataFrame')
|
||||
@patch('model_manager.activities.mlflow.max')
|
||||
async def test_request_transform_success(mock_max, mock_dataframe, mlflow):
|
||||
mock_max.return_value = '2024-01-02'
|
||||
# Mock input data
|
||||
input_data = {
|
||||
**metadata,
|
||||
'data': [
|
||||
{
|
||||
'timestamp': '2024-01-01',
|
||||
'variable': 'var1',
|
||||
'value': 1.0,
|
||||
'created_at': '2024-01-01 12:00:00',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-01',
|
||||
'variable': 'var2',
|
||||
'value': 2.0,
|
||||
'created_at': '2024-01-01 12:00:00',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-02',
|
||||
'variable': 'var1',
|
||||
'value': 3.0,
|
||||
'created_at': '2024-01-02 12:00:00',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-02',
|
||||
'variable': 'var2',
|
||||
'value': 4.0,
|
||||
'created_at': '2024-01-02 12:00:00',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-02',
|
||||
'variable': 'var1',
|
||||
'value': 1.0,
|
||||
'created_at': '2024-01-01 12:00:00',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-02',
|
||||
'variable': 'var2',
|
||||
'value': 1.0,
|
||||
'created_at': '2024-01-01 12:00:00',
|
||||
},
|
||||
],
|
||||
'model_name': 'test_model',
|
||||
'model_config': {},
|
||||
}
|
||||
|
||||
# Mock the transform response
|
||||
expected_response = {'prediction': [0.5, 0.6], 'timestamp': ['2024-01-01', '2024-01-02']}
|
||||
mlflow.model_monitoring_repository.transform.return_value = expected_response
|
||||
|
||||
mock_dataframe.return_value.sort_values.return_value = mock_dataframe.return_value
|
||||
mock_dataframe.return_value.drop_duplicates.return_value = mock_dataframe.return_value
|
||||
|
||||
# Call the method
|
||||
response_data = await mlflow.request_transform(input_data)
|
||||
|
||||
# Verify the data was correctly transformed
|
||||
mock_dataframe.assert_called_once_with(input_data['data'])
|
||||
mock_dataframe.return_value.pivot.assert_called_once_with(
|
||||
index='timestamp', columns='variable', values='value'
|
||||
)
|
||||
mock_dataframe = mock_dataframe.return_value.pivot.return_value
|
||||
mock_dataframe.fillna.assert_called_once_with(np.nan, inplace=True)
|
||||
# mock_dataframe.reset_index.assert_called_once()
|
||||
mock_dataframe.columns.name = None
|
||||
|
||||
# Verify the response
|
||||
assert response_data == expected_response
|
||||
|
||||
# Verify the repository was called with correct arguments
|
||||
mlflow.model_monitoring_repository.transform.assert_called_once_with(
|
||||
'test_model', mock_dataframe, {}, metadata['metadata']
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.activities.mlflow.DataFrame')
|
||||
@patch('model_manager.activities.mlflow.to_datetime')
|
||||
@patch('model_manager.activities.mlflow.max')
|
||||
async def test_request_predict(mock_max, mock_to_datetime, mock_dataframe, mlflow):
|
||||
mock_max.return_value = '2024-01-02'
|
||||
# Mock input data
|
||||
input_data = {
|
||||
**metadata,
|
||||
'data': {
|
||||
'variable': {
|
||||
'2024-01-01': 'var1',
|
||||
'2024-01-02': 'var2',
|
||||
'2024-01-03': 'var1',
|
||||
'2024-01-04': 'var2',
|
||||
},
|
||||
'value': {'2024-01-01': 1.0, '2024-01-02': 2.0, '2024-01-03': 3.0, '2024-01-04': 4.0},
|
||||
},
|
||||
'model_name': 'test_model',
|
||||
'model_config': {},
|
||||
}
|
||||
|
||||
# Mock the predict response
|
||||
expected_response = {'prediction': [0.5, 0.6]}
|
||||
mlflow.model_monitoring_repository.predict.return_value = expected_response
|
||||
|
||||
# Call the method
|
||||
response_data = await mlflow.request_predict(input_data)
|
||||
|
||||
mock_dataframe.assert_called_once_with(input_data['data'])
|
||||
mock_dataframe.return_value.replace.assert_called_once_with(np.nan, None, inplace=True)
|
||||
mock_dataframe.return_value.__setitem__.assert_any_call(
|
||||
'timestamp', mock_to_datetime.return_value.dt.strftime.return_value
|
||||
)
|
||||
|
||||
mock_to_datetime.assert_called_once_with(
|
||||
mock_dataframe.return_value.__getitem__.return_value, format=DATETIME_FORMAT_WITH_TZ
|
||||
)
|
||||
mock_to_datetime.return_value.dt.strftime.assert_called_once_with(DATETIME_FORMAT)
|
||||
|
||||
# Verify the response
|
||||
assert response_data == expected_response
|
||||
|
||||
# Verify the repository was called with correct arguments
|
||||
mlflow.model_monitoring_repository.predict.assert_called_once_with(
|
||||
'test_model', mock_dataframe.return_value, {}, metadata['metadata']
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_retrain_model(mlflow):
|
||||
data = {
|
||||
'model_id': [4, 5, 6, 7],
|
||||
'created_at': [1, 2, 3, 4],
|
||||
'timestamp': [1, 1, 2, 2],
|
||||
'variable': ['var1', 'var2', 'var1', 'var2'],
|
||||
'value': [1, 2, 3, 4],
|
||||
}
|
||||
|
||||
mlflow.model_monitoring_repository.retrain_model.return_value = (
|
||||
'Model retrained successfully',
|
||||
'test',
|
||||
)
|
||||
|
||||
response = await mlflow.retrain_model({**metadata, 'data': data, 'model_name': 'test_model'})
|
||||
|
||||
mlflow.model_monitoring_repository.retrain_model.assert_called_once()
|
||||
|
||||
assert response == {
|
||||
'status': 'Model retrained successfully',
|
||||
'timestamp': 2,
|
||||
'experiment': 'test',
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_retrain_model_error(mlflow):
|
||||
mlflow.model_monitoring_repository.retrain_model.side_effect = Exception(
|
||||
'Error retraining model'
|
||||
)
|
||||
|
||||
data = {
|
||||
'model_id': [4, 5, 6, 7],
|
||||
'created_at': [1, 2, 3, 4],
|
||||
'timestamp': [1, 1, 2, 2],
|
||||
'variable': ['var1', 'var2', 'var1', 'var2'],
|
||||
'value': [1, 2, 3, 4],
|
||||
}
|
||||
|
||||
try:
|
||||
await mlflow.retrain_model({**metadata, 'data': data, 'model_name': 'test_model'})
|
||||
except Exception as e: # noqa: BLE001
|
||||
assert str(e) == 'Error retraining model'
|
||||
mlflow.send_notification.assert_called_once_with(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='RETRAIN_MODEL_ERROR',
|
||||
message='Error retraining model test_model: Error retraining model',
|
||||
block='retrain_model',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=ANY,
|
||||
)
|
||||
else:
|
||||
raise AssertionError('No exception raised')
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_update_production_model(mlflow):
|
||||
mlflow.model_monitoring_repository.update_production_model.return_value = {
|
||||
'data1': 1,
|
||||
'data2': 2,
|
||||
}
|
||||
|
||||
input_data = {
|
||||
**metadata,
|
||||
'model_name': 'test_model',
|
||||
'model_id': 1,
|
||||
'experiment': 'test',
|
||||
'timestamp': 2,
|
||||
'status': 'success',
|
||||
}
|
||||
|
||||
response = await mlflow.update_production_model(input_data)
|
||||
|
||||
mlflow.model_monitoring_repository.update_production_model.assert_called_once_with(
|
||||
experiment='test', model_name='test_model'
|
||||
)
|
||||
|
||||
assert response == {
|
||||
'data1': {0: 1},
|
||||
'data2': {0: 2},
|
||||
'model_id': {0: 1},
|
||||
'model_name': {0: 'test_model'},
|
||||
'timestamp': {0: 2},
|
||||
'status': {0: 'success'},
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_update_production_model_error(mlflow):
|
||||
mlflow.model_monitoring_repository.update_production_model.side_effect = Exception(
|
||||
'Error updating production model'
|
||||
)
|
||||
|
||||
input_data = {
|
||||
**metadata,
|
||||
'model_name': 'test_model',
|
||||
'model_id': 1,
|
||||
'experiment': 'test',
|
||||
'timestamp': 2,
|
||||
'status': 'success',
|
||||
}
|
||||
|
||||
try:
|
||||
await mlflow.update_production_model(input_data)
|
||||
except Exception as e: # noqa: BLE001
|
||||
assert str(e) == 'Error updating production model'
|
||||
mlflow.send_notification.assert_called_once_with(
|
||||
metadata=metadata['metadata'],
|
||||
notification_id='UPDATE_PRODUCTION_MODEL_ERROR',
|
||||
message='Error updating production model test_model: Error updating production model',
|
||||
block='update_production_model',
|
||||
level=NotificationLevel.ERROR,
|
||||
attachment_content=ANY,
|
||||
)
|
||||
else:
|
||||
raise AssertionError('No exception raised')
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_save_model_success(mlflow):
|
||||
"""Test save_model successfully saves model and artifacts to MLflow."""
|
||||
|
||||
202
tests/sientia/test_metrics.py
Normal file
202
tests/sientia/test_metrics.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Unit tests for sientia metrics module."""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from model_manager.sientia.metrics import mae, mse, r2
|
||||
|
||||
|
||||
def test_mse_perfect_predictions():
|
||||
"""Test MSE with perfect predictions returns 0.0."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
|
||||
result = mse(real_data, predictions)
|
||||
|
||||
assert result == 0.0
|
||||
|
||||
|
||||
def test_mse_with_errors():
|
||||
"""Test MSE calculation with prediction errors."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([1.5, 2.5, 3.5, 4.5, 5.5])
|
||||
|
||||
result = mse(real_data, predictions)
|
||||
|
||||
# MSE = mean((0.5^2, 0.5^2, 0.5^2, 0.5^2, 0.5^2)) = 0.25
|
||||
assert result == 0.25
|
||||
|
||||
|
||||
def test_mse_with_integer_input():
|
||||
"""Test MSE handles integer input and converts to float64."""
|
||||
real_data = pd.Series([1, 2, 3, 4, 5])
|
||||
predictions = pd.Series([2, 3, 4, 5, 6])
|
||||
|
||||
result = mse(real_data, predictions)
|
||||
|
||||
# MSE = mean((1^2, 1^2, 1^2, 1^2, 1^2)) = 1.0
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
def test_mse_with_large_errors():
|
||||
"""Test MSE with large prediction errors."""
|
||||
real_data = pd.Series([10.0, 20.0, 30.0])
|
||||
predictions = pd.Series([5.0, 15.0, 25.0])
|
||||
|
||||
result = mse(real_data, predictions)
|
||||
|
||||
# MSE = mean((25, 25, 25)) = 25.0
|
||||
assert result == 25.0
|
||||
|
||||
|
||||
def test_mse_rounds_to_two_decimals():
|
||||
"""Test MSE rounds result to 2 decimal places."""
|
||||
real_data = pd.Series([1.111, 2.222, 3.333])
|
||||
predictions = pd.Series([1.222, 2.333, 3.444])
|
||||
|
||||
result = mse(real_data, predictions)
|
||||
|
||||
# Result should be rounded to 2 decimals
|
||||
assert isinstance(result, float)
|
||||
assert len(str(result).split('.')[-1]) <= 2
|
||||
|
||||
|
||||
def test_mae_perfect_predictions():
|
||||
"""Test MAE with perfect predictions returns 0.0."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
|
||||
result = mae(real_data, predictions)
|
||||
|
||||
assert result == 0.0
|
||||
|
||||
|
||||
def test_mae_with_errors():
|
||||
"""Test MAE calculation with prediction errors."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([1.5, 2.5, 3.5, 4.5, 5.5])
|
||||
|
||||
result = mae(real_data, predictions)
|
||||
|
||||
# MAE = mean(|0.5|, |0.5|, |0.5|, |0.5|, |0.5|) = 0.5
|
||||
assert result == 0.5
|
||||
|
||||
|
||||
def test_mae_with_integer_input():
|
||||
"""Test MAE handles integer input and converts to float64."""
|
||||
real_data = pd.Series([1, 2, 3, 4, 5])
|
||||
predictions = pd.Series([2, 3, 4, 5, 6])
|
||||
|
||||
result = mae(real_data, predictions)
|
||||
|
||||
# MAE = mean(|1|, |1|, |1|, |1|, |1|) = 1.0
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
def test_mae_with_negative_errors():
|
||||
"""Test MAE with negative prediction errors (absolute value)."""
|
||||
real_data = pd.Series([10.0, 20.0, 30.0])
|
||||
predictions = pd.Series([15.0, 25.0, 35.0])
|
||||
|
||||
result = mae(real_data, predictions)
|
||||
|
||||
# MAE = mean(|5|, |5|, |5|) = 5.0
|
||||
assert result == 5.0
|
||||
|
||||
|
||||
def test_mae_rounds_to_two_decimals():
|
||||
"""Test MAE rounds result to 2 decimal places."""
|
||||
real_data = pd.Series([1.111, 2.222, 3.333])
|
||||
predictions = pd.Series([1.222, 2.333, 3.444])
|
||||
|
||||
result = mae(real_data, predictions)
|
||||
|
||||
# Result should be rounded to 2 decimals
|
||||
assert isinstance(result, float)
|
||||
assert len(str(result).split('.')[-1]) <= 2
|
||||
|
||||
|
||||
def test_r2_perfect_predictions():
|
||||
"""Test R2 with perfect predictions returns 1.0."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
|
||||
result = r2(real_data, predictions)
|
||||
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
def test_r2_with_good_predictions():
|
||||
"""Test R2 calculation with good predictions."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([1.1, 2.1, 2.9, 4.1, 4.9])
|
||||
|
||||
result = r2(real_data, predictions)
|
||||
|
||||
# R2 should be close to 1.0 for good predictions
|
||||
assert result > 0.9
|
||||
assert result <= 1.0
|
||||
|
||||
|
||||
def test_r2_with_integer_input():
|
||||
"""Test R2 handles integer input and converts to float64."""
|
||||
real_data = pd.Series([1, 2, 3, 4, 5])
|
||||
predictions = pd.Series([1, 2, 3, 4, 5])
|
||||
|
||||
result = r2(real_data, predictions)
|
||||
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
def test_r2_with_poor_predictions():
|
||||
"""Test R2 with poor predictions returns low score."""
|
||||
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
predictions = pd.Series([5.0, 4.0, 3.0, 2.0, 1.0])
|
||||
|
||||
result = r2(real_data, predictions)
|
||||
|
||||
# R2 should be negative for predictions worse than mean
|
||||
assert result < 0
|
||||
|
||||
|
||||
def test_r2_rounds_to_two_decimals():
|
||||
"""Test R2 rounds result to 2 decimal places."""
|
||||
real_data = pd.Series([1.111, 2.222, 3.333, 4.444, 5.555])
|
||||
predictions = pd.Series([1.222, 2.333, 3.444, 4.555, 5.666])
|
||||
|
||||
result = r2(real_data, predictions)
|
||||
|
||||
# Result should be rounded to 2 decimals
|
||||
assert isinstance(result, float)
|
||||
assert len(str(result).split('.')[-1]) <= 2
|
||||
|
||||
|
||||
def test_mse_with_mixed_positive_negative():
|
||||
"""Test MSE with mixed positive and negative values."""
|
||||
real_data = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||
predictions = pd.Series([-4.0, -1.0, 1.0, 4.0, 8.0])
|
||||
|
||||
result = mse(real_data, predictions)
|
||||
|
||||
# MSE = mean((1^2, 1^2, 1^2, 1^2, 1^2)) = 1.0
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
def test_mae_with_mixed_positive_negative():
|
||||
"""Test MAE with mixed positive and negative values."""
|
||||
real_data = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||
predictions = pd.Series([-4.0, -1.0, 1.0, 4.0, 8.0])
|
||||
|
||||
result = mae(real_data, predictions)
|
||||
|
||||
# MAE = mean(|1|, |1|, |1|, |1|, |1|) = 1.0
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
def test_r2_with_mixed_positive_negative():
|
||||
"""Test R2 with mixed positive and negative values."""
|
||||
real_data = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||
predictions = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||
|
||||
result = r2(real_data, predictions)
|
||||
|
||||
assert result == 1.0
|
||||
314
tests/sientia/test_model_serving.py
Normal file
314
tests/sientia/test_model_serving.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Unit tests for ModelServing class."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pandas as pd
|
||||
from pytest import raises
|
||||
|
||||
from model_manager.sientia.exceptions import SientiaMlException
|
||||
from model_manager.sientia.model_serving import ModelServing
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch.dict('os.environ', {}, clear=True)
|
||||
def test_init_with_all_credentials(mock_set_tracking_uri):
|
||||
"""Test initialization with tracking URI, username, and password."""
|
||||
tracking_uri = 'http://mlflow.example.com'
|
||||
username = 'test_user'
|
||||
password = 'test_pass'
|
||||
logger = MagicMock()
|
||||
|
||||
ModelServing(tracking_uri=tracking_uri, username=username, password=password, logger=logger)
|
||||
|
||||
mock_set_tracking_uri.assert_called_once_with(tracking_uri)
|
||||
import os
|
||||
|
||||
assert os.environ['MLFLOW_TRACKING_USERNAME'] == username
|
||||
assert os.environ['MLFLOW_TRACKING_PASSWORD'] == password
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch.dict('os.environ', {}, clear=True)
|
||||
def test_init_without_credentials(mock_set_tracking_uri):
|
||||
"""Test initialization without username and password."""
|
||||
tracking_uri = 'http://mlflow.example.com'
|
||||
|
||||
ModelServing(tracking_uri=tracking_uri)
|
||||
|
||||
mock_set_tracking_uri.assert_called_once_with(tracking_uri)
|
||||
import os
|
||||
|
||||
assert 'MLFLOW_TRACKING_USERNAME' not in os.environ
|
||||
assert 'MLFLOW_TRACKING_PASSWORD' not in os.environ
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch.dict('os.environ', {}, clear=True)
|
||||
def test_init_with_only_username(mock_set_tracking_uri):
|
||||
"""Test initialization with only username (no password)."""
|
||||
tracking_uri = 'http://mlflow.example.com'
|
||||
username = 'test_user'
|
||||
|
||||
ModelServing(tracking_uri=tracking_uri, username=username)
|
||||
|
||||
mock_set_tracking_uri.assert_called_once_with(tracking_uri)
|
||||
import os
|
||||
|
||||
assert os.environ['MLFLOW_TRACKING_USERNAME'] == username
|
||||
assert 'MLFLOW_TRACKING_PASSWORD' not in os.environ
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch.dict('os.environ', {}, clear=True)
|
||||
def test_init_with_only_password(mock_set_tracking_uri):
|
||||
"""Test initialization with only password (no username)."""
|
||||
tracking_uri = 'http://mlflow.example.com'
|
||||
password = 'test_pass'
|
||||
|
||||
ModelServing(tracking_uri=tracking_uri, password=password)
|
||||
|
||||
mock_set_tracking_uri.assert_called_once_with(tracking_uri)
|
||||
import os
|
||||
|
||||
assert 'MLFLOW_TRACKING_USERNAME' not in os.environ
|
||||
assert os.environ['MLFLOW_TRACKING_PASSWORD'] == password
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.search_runs')
|
||||
def test_search_runs_by_name_success(mock_search_runs, mock_set_tracking_uri):
|
||||
"""Test successful search_runs_by_name."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
expected_df = pd.DataFrame({'run_id': ['123', '456'], 'status': ['FINISHED', 'RUNNING']})
|
||||
mock_search_runs.return_value = expected_df
|
||||
|
||||
experiment_names = ['experiment1', 'experiment2']
|
||||
result = model_serving.search_runs_by_name(experiment_names)
|
||||
|
||||
mock_search_runs.assert_called_once_with(experiment_names=experiment_names, order_by=None)
|
||||
pd.testing.assert_frame_equal(result, expected_df)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.search_runs')
|
||||
def test_search_runs_by_name_with_order_by(mock_search_runs, mock_set_tracking_uri):
|
||||
"""Test search_runs_by_name with order_by parameter."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
expected_df = pd.DataFrame({'run_id': ['123'], 'status': ['FINISHED']})
|
||||
mock_search_runs.return_value = expected_df
|
||||
|
||||
experiment_names = ['experiment1']
|
||||
order_by = ['start_time DESC']
|
||||
result = model_serving.search_runs_by_name(experiment_names, order_by=order_by)
|
||||
|
||||
mock_search_runs.assert_called_once_with(experiment_names=experiment_names, order_by=order_by)
|
||||
pd.testing.assert_frame_equal(result, expected_df)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.search_runs')
|
||||
@patch('model_manager.sientia.model_serving.logging.error')
|
||||
def test_search_runs_by_name_raises_exception(
|
||||
mock_logging_error, mock_search_runs, mock_set_tracking_uri
|
||||
):
|
||||
"""Test search_runs_by_name raises TypeError due to bug in line 80 of model_serving.py."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
exception = SientiaMlException(message='Search failed')
|
||||
mock_search_runs.side_effect = exception
|
||||
|
||||
# The code has a bug on line 80: "raise SientiaMlException from e"
|
||||
# This raises TypeError because SientiaMlException requires 'message' argument
|
||||
with raises(TypeError, match="missing 1 required positional argument: 'message'"):
|
||||
model_serving.search_runs_by_name(['experiment1'])
|
||||
|
||||
mock_logging_error.assert_called_once()
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_experiment')
|
||||
def test_set_experiment(mock_set_experiment, mock_set_tracking_uri):
|
||||
"""Test set_experiment method."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
experiment_identifier = 'my_experiment'
|
||||
model_serving.set_experiment(experiment_identifier)
|
||||
|
||||
mock_set_experiment.assert_called_once_with(experiment_identifier)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.sklearn.log_model')
|
||||
def test_log_model(mock_log_model, mock_set_tracking_uri):
|
||||
"""Test log_model method."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
sk_model = MagicMock()
|
||||
artifact_path = 'model'
|
||||
model_serving.log_model(sk_model, artifact_path)
|
||||
|
||||
mock_log_model.assert_called_once()
|
||||
call_args = mock_log_model.call_args
|
||||
assert call_args[0][0] == sk_model
|
||||
assert call_args[0][1] == artifact_path
|
||||
assert 'extra_pip_requirements' in call_args[1]
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.sklearn.log_model')
|
||||
def test_log_model_with_kwargs(mock_log_model, mock_set_tracking_uri):
|
||||
"""Test log_model method with additional kwargs."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
sk_model = MagicMock()
|
||||
artifact_path = 'model'
|
||||
registered_model_name = 'my_model'
|
||||
model_serving.log_model(sk_model, artifact_path, registered_model_name=registered_model_name)
|
||||
|
||||
mock_log_model.assert_called_once()
|
||||
call_args = mock_log_model.call_args
|
||||
assert call_args[1]['registered_model_name'] == registered_model_name
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.log_param')
|
||||
def test_log_param(mock_log_param, mock_set_tracking_uri):
|
||||
"""Test log_param method."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
key = 'learning_rate'
|
||||
value = 0.01
|
||||
model_serving.log_param(key, value)
|
||||
|
||||
mock_log_param.assert_called_once_with(key, value)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.log_metric')
|
||||
def test_log_metric(mock_log_metric, mock_set_tracking_uri):
|
||||
"""Test log_metric method."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
key = 'accuracy'
|
||||
value = 0.95
|
||||
model_serving.log_metric(key, value)
|
||||
|
||||
mock_log_metric.assert_called_once_with(key, value)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.log_artifact')
|
||||
def test_log_artifact_with_all_params(mock_log_artifact, mock_set_tracking_uri):
|
||||
"""Test log_artifact method with all parameters."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
local_path = '/path/to/artifact.txt'
|
||||
artifact_path = 'artifacts'
|
||||
run_id = 'run_123'
|
||||
model_serving.log_artifact(local_path, artifact_path, run_id)
|
||||
|
||||
mock_log_artifact.assert_called_once_with(
|
||||
local_path=local_path, artifact_path=artifact_path, run_id=run_id
|
||||
)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.log_artifact')
|
||||
def test_log_artifact_with_minimal_params(mock_log_artifact, mock_set_tracking_uri):
|
||||
"""Test log_artifact method with only required parameter."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
local_path = '/path/to/artifact.txt'
|
||||
model_serving.log_artifact(local_path)
|
||||
|
||||
mock_log_artifact.assert_called_once_with(
|
||||
local_path=local_path, artifact_path=None, run_id=None
|
||||
)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.start_run')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.end_run')
|
||||
def test_save_experiment_context_manager(mock_end_run, mock_start_run, mock_set_tracking_uri):
|
||||
"""Test save_experiment context manager."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
mock_run = MagicMock()
|
||||
mock_start_run.return_value = mock_run
|
||||
|
||||
with model_serving.save_experiment(run_name='test_run') as run:
|
||||
assert run == mock_run
|
||||
|
||||
mock_start_run.assert_called_once_with(
|
||||
run_id=None,
|
||||
experiment_id=None,
|
||||
run_name='test_run',
|
||||
nested=False,
|
||||
tags=None,
|
||||
description=None,
|
||||
log_system_metrics=None,
|
||||
)
|
||||
mock_end_run.assert_called_once()
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.start_run')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.end_run')
|
||||
def test_save_experiment_with_all_params(mock_end_run, mock_start_run, mock_set_tracking_uri):
|
||||
"""Test save_experiment with all parameters."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
mock_run = MagicMock()
|
||||
mock_start_run.return_value = mock_run
|
||||
|
||||
run_id = 'run_123'
|
||||
experiment_id = 'exp_456'
|
||||
run_name = 'test_run'
|
||||
nested = True
|
||||
tags = {'key': 'value'}
|
||||
description = 'Test description'
|
||||
log_system_metrics = True
|
||||
|
||||
with model_serving.save_experiment(
|
||||
run_id=run_id,
|
||||
experiment_id=experiment_id,
|
||||
run_name=run_name,
|
||||
nested=nested,
|
||||
tags=tags,
|
||||
description=description,
|
||||
log_system_metrics=log_system_metrics,
|
||||
) as run:
|
||||
assert run == mock_run
|
||||
|
||||
mock_start_run.assert_called_once_with(
|
||||
run_id=run_id,
|
||||
experiment_id=experiment_id,
|
||||
run_name=run_name,
|
||||
nested=nested,
|
||||
tags=tags,
|
||||
description=description,
|
||||
log_system_metrics=log_system_metrics,
|
||||
)
|
||||
mock_end_run.assert_called_once()
|
||||
|
||||
|
||||
@patch('model_manager.sientia.model_serving.mlflow.set_tracking_uri')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.start_run')
|
||||
@patch('model_manager.sientia.model_serving.mlflow.end_run')
|
||||
def test_save_experiment_ensures_end_run_on_exception(
|
||||
mock_end_run, mock_start_run, mock_set_tracking_uri
|
||||
):
|
||||
"""Test save_experiment ensures end_run is called even when exception occurs."""
|
||||
model_serving = ModelServing(tracking_uri='http://mlflow.example.com')
|
||||
|
||||
mock_run = MagicMock()
|
||||
mock_start_run.return_value = mock_run
|
||||
|
||||
with raises(ValueError):
|
||||
with model_serving.save_experiment(run_name='test_run'):
|
||||
raise ValueError('Test exception')
|
||||
|
||||
mock_start_run.assert_called_once()
|
||||
mock_end_run.assert_called_once()
|
||||
692
tests/sientia/test_models.py
Normal file
692
tests/sientia/test_models.py
Normal file
@@ -0,0 +1,692 @@
|
||||
"""Unit tests for sientia models module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pytest import raises
|
||||
|
||||
from model_manager.sientia.models import (
|
||||
DataPreprocessor,
|
||||
LinearRegressionModel,
|
||||
)
|
||||
|
||||
|
||||
class _IterableWithContains:
|
||||
def __init__(self, iterable, contains_values):
|
||||
self._iterable = iterable
|
||||
self._contains = set(contains_values)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._iterable)
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self._contains
|
||||
|
||||
|
||||
# LinearRegressionModel Tests
|
||||
|
||||
|
||||
def test_linear_regression_model_init_default():
|
||||
"""Test LinearRegressionModel initialization with default parameters."""
|
||||
model = LinearRegressionModel()
|
||||
|
||||
assert model.target_variable == ''
|
||||
assert model.variable_columns is None
|
||||
assert model.model_params is None
|
||||
assert model.clipping is None
|
||||
assert model.weights is None
|
||||
assert model.q1_target is None
|
||||
assert model.q3_target is None
|
||||
|
||||
|
||||
def test_linear_regression_model_init_with_params():
|
||||
"""Test LinearRegressionModel initialization with parameters."""
|
||||
target = 'target'
|
||||
variables = ['var1', 'var2']
|
||||
params = {'fit_intercept': True}
|
||||
clipping = {'min': 0, 'max': 100}
|
||||
weights = {'var1': 0.5, 'var2': 0.3}
|
||||
|
||||
model = LinearRegressionModel(
|
||||
target_variable=target,
|
||||
variable_columns=variables,
|
||||
model_params=params,
|
||||
clipping=clipping,
|
||||
weights=weights,
|
||||
)
|
||||
|
||||
assert model.target_variable == target
|
||||
assert model.variable_columns == variables
|
||||
assert model.model_params == params
|
||||
assert model.clipping == clipping
|
||||
assert model.weights == weights
|
||||
|
||||
|
||||
def test_linear_regression_model_fit():
|
||||
"""Test LinearRegressionModel fit method."""
|
||||
model = LinearRegressionModel(target_variable='target', variable_columns=['var1', 'var2'])
|
||||
|
||||
data = pd.DataFrame(
|
||||
{'var1': [1, 2, 3, 4, 5], 'var2': [2, 3, 4, 5, 6], 'target': [3, 5, 7, 9, 11]}
|
||||
)
|
||||
|
||||
result = model.fit(data)
|
||||
|
||||
assert result is model
|
||||
assert model.q1_target is not None
|
||||
assert model.q3_target is not None
|
||||
assert model.weights is not None
|
||||
assert 'Bias' in model.weights
|
||||
|
||||
|
||||
def test_linear_regression_model_fit_without_variable_columns():
|
||||
"""Test LinearRegressionModel fit raises AssertionError without variable_columns."""
|
||||
model = LinearRegressionModel(target_variable='target')
|
||||
|
||||
data = pd.DataFrame({'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
|
||||
with raises(AssertionError, match='variable_columns must be set before fitting'):
|
||||
model.fit(data)
|
||||
|
||||
|
||||
def test_linear_regression_model_predict_without_clipping():
|
||||
"""Test LinearRegressionModel predict without clipping."""
|
||||
model = LinearRegressionModel(target_variable='target', variable_columns=['var1', 'var2'])
|
||||
|
||||
train_data = pd.DataFrame(
|
||||
{'var1': [1, 2, 3, 4, 5], 'var2': [2, 3, 4, 5, 6], 'target': [3, 5, 7, 9, 11]}
|
||||
)
|
||||
model.fit(train_data)
|
||||
|
||||
test_data = pd.DataFrame({'var1': [6, 7], 'var2': [7, 8]})
|
||||
predictions = model.predict(test_data)
|
||||
|
||||
assert isinstance(predictions, np.ndarray)
|
||||
assert len(predictions) == 2
|
||||
|
||||
|
||||
def test_linear_regression_model_predict_with_clipping_max():
|
||||
"""Test LinearRegressionModel predict with clipping max."""
|
||||
model = LinearRegressionModel(
|
||||
target_variable='target', variable_columns=['var1'], clipping={'min': 0, 'max': 5}
|
||||
)
|
||||
|
||||
train_data = pd.DataFrame({'var1': [1, 2, 3, 4, 5], 'target': [1, 2, 3, 4, 5]})
|
||||
model.fit(train_data)
|
||||
|
||||
test_data = pd.DataFrame({'var1': [10]})
|
||||
predictions = model.predict(test_data)
|
||||
|
||||
assert predictions[0] == model.q3_target
|
||||
|
||||
|
||||
def test_linear_regression_model_predict_with_clipping_min():
|
||||
"""Test LinearRegressionModel predict with clipping min."""
|
||||
model = LinearRegressionModel(
|
||||
target_variable='target', variable_columns=['var1'], clipping={'min': 0, 'max': 10}
|
||||
)
|
||||
|
||||
train_data = pd.DataFrame({'var1': [1, 2, 3, 4, 5], 'target': [1, 2, 3, 4, 5]})
|
||||
model.fit(train_data)
|
||||
|
||||
test_data = pd.DataFrame({'var1': [-10]})
|
||||
predictions = model.predict(test_data)
|
||||
|
||||
assert predictions[0] == model.q1_target
|
||||
|
||||
|
||||
def test_linear_regression_model_predict_with_clipping_within_range():
|
||||
"""Test LinearRegressionModel predict with clipping but value within range."""
|
||||
model = LinearRegressionModel(
|
||||
target_variable='target', variable_columns=['var1'], clipping={'min': 0, 'max': 10}
|
||||
)
|
||||
|
||||
train_data = pd.DataFrame({'var1': [1, 2, 3, 4, 5], 'target': [1, 2, 3, 4, 5]})
|
||||
model.fit(train_data)
|
||||
|
||||
test_data = pd.DataFrame({'var1': [3]})
|
||||
predictions = model.predict(test_data)
|
||||
|
||||
# Prediction should be within range and not clipped
|
||||
assert 0 <= predictions[0] <= 10
|
||||
|
||||
|
||||
# DataPreprocessor Tests
|
||||
|
||||
|
||||
def test_data_preprocessor_init_default():
|
||||
"""Test DataPreprocessor initialization with default parameters."""
|
||||
preprocessor = DataPreprocessor()
|
||||
|
||||
assert preprocessor.date_column == ''
|
||||
assert preprocessor.target_variable == ''
|
||||
assert preprocessor.input_columns is None
|
||||
assert preprocessor.nan_treatment is None
|
||||
assert preprocessor.lag_train == {}
|
||||
assert preprocessor.lag_transform == {}
|
||||
assert preprocessor.scaler is None
|
||||
|
||||
|
||||
def test_data_preprocessor_init_with_standard_scaler():
|
||||
"""Test DataPreprocessor initialization with Standard Scaler."""
|
||||
preprocessor = DataPreprocessor(scaler_name='Standard Scaler')
|
||||
|
||||
assert preprocessor.scaler is not None
|
||||
|
||||
|
||||
def test_data_preprocessor_init_with_none_scaler():
|
||||
"""Test DataPreprocessor initialization with None scaler."""
|
||||
preprocessor = DataPreprocessor(scaler_name='None')
|
||||
|
||||
assert preprocessor.scaler is None
|
||||
|
||||
|
||||
def test_data_preprocessor_init_with_unknown_scaler():
|
||||
"""Test DataPreprocessor initialization with unknown scaler."""
|
||||
preprocessor = DataPreprocessor(scaler_name='Unknown')
|
||||
|
||||
assert preprocessor.scaler is None
|
||||
|
||||
|
||||
def test_data_preprocessor_init_with_custom_steps_order():
|
||||
"""Test DataPreprocessor initialization with custom steps order."""
|
||||
custom_steps = ['Normalization', 'Feature Creation']
|
||||
preprocessor = DataPreprocessor(steps_order=custom_steps)
|
||||
|
||||
assert 'Normalization' in preprocessor.steps_order
|
||||
assert 'Feature Creation' in preprocessor.steps_order
|
||||
assert len(preprocessor.steps_order) == 7
|
||||
|
||||
|
||||
def test_data_preprocessor_get_scaler():
|
||||
"""Test DataPreprocessor get_scaler method."""
|
||||
preprocessor = DataPreprocessor(scaler_name='Standard Scaler')
|
||||
|
||||
scaler = preprocessor.get_scaler()
|
||||
|
||||
assert scaler is not None
|
||||
|
||||
|
||||
@patch('model_manager.sientia.models.treat_nan')
|
||||
def test_data_preprocessor_treat_discontinuities_with_treatment(mock_treat_nan):
|
||||
"""Test treat_discontinuities with nan_treatment."""
|
||||
preprocessor = DataPreprocessor(nan_treatment='drop')
|
||||
data = pd.DataFrame({'col1': [1, 2, np.nan]})
|
||||
expected_data = pd.DataFrame({'col1': [1, 2]})
|
||||
mock_treat_nan.return_value = expected_data
|
||||
|
||||
result = preprocessor.treat_discontinuities(data)
|
||||
|
||||
mock_treat_nan.assert_called_once_with(data, 'drop')
|
||||
pd.testing.assert_frame_equal(result, expected_data)
|
||||
|
||||
|
||||
def test_data_preprocessor_treat_discontinuities_without_treatment():
|
||||
"""Test treat_discontinuities without nan_treatment."""
|
||||
preprocessor = DataPreprocessor()
|
||||
data = pd.DataFrame({'col1': [1, 2, 3]})
|
||||
|
||||
result = preprocessor.treat_discontinuities(data)
|
||||
|
||||
pd.testing.assert_frame_equal(result, data)
|
||||
|
||||
|
||||
def test_data_preprocessor_lag_selection_with_lag():
|
||||
"""Test lag_selection with lag."""
|
||||
preprocessor = DataPreprocessor()
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
lag_dict = {'var1': 1}
|
||||
|
||||
result = preprocessor.lag_selection(data, lag_dict)
|
||||
|
||||
assert len(result) == 4
|
||||
assert result['var1'].iloc[0] == 1
|
||||
|
||||
|
||||
def test_data_preprocessor_lag_selection_without_lag():
|
||||
"""Test lag_selection without lag."""
|
||||
preprocessor = DataPreprocessor()
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
lag_dict = {}
|
||||
|
||||
result = preprocessor.lag_selection(data, lag_dict)
|
||||
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
def test_data_preprocessor_lag_selection_with_zero_lag():
|
||||
"""Test lag_selection with zero lag."""
|
||||
preprocessor = DataPreprocessor()
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
lag_dict = {'var1': 0}
|
||||
|
||||
result = preprocessor.lag_selection(data, lag_dict)
|
||||
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
@patch('model_manager.sientia.models.TimeSeriesDiscontinuityAnalyzer')
|
||||
def test_data_preprocessor_treat_static_windows(mock_analyzer_class):
|
||||
"""Test treat_static_windows method."""
|
||||
preprocessor = DataPreprocessor(static_threshold=3)
|
||||
data = pd.DataFrame({'col1': [1, 1, 1, 2, 3]})
|
||||
|
||||
mock_analyzer = MagicMock()
|
||||
mock_analyzer_class.return_value = mock_analyzer
|
||||
mock_analyzer.get_treated_data.return_value = data
|
||||
|
||||
preprocessor.treat_static_windows(data)
|
||||
|
||||
mock_analyzer.infer_frequency.assert_called_once()
|
||||
assert mock_analyzer.identify_static_windows.called
|
||||
assert mock_analyzer.treat_static_windows.called
|
||||
|
||||
|
||||
def test_data_preprocessor_treat_static_windows_without_threshold():
|
||||
"""Test treat_static_windows without threshold."""
|
||||
preprocessor = DataPreprocessor()
|
||||
data = pd.DataFrame({'col1': [1, 2, 3]})
|
||||
|
||||
result = preprocessor.treat_static_windows(data)
|
||||
|
||||
pd.testing.assert_frame_equal(result, data)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.models.limit_dataset')
|
||||
def test_data_preprocessor_adjust_limits(mock_limit_dataset):
|
||||
"""Test adjust_limits method."""
|
||||
preprocessor = DataPreprocessor(low_lim={'col1': 0}, upp_lim={'col1': 10})
|
||||
data = pd.DataFrame({'col1': [1, 2, 3]})
|
||||
expected_data = pd.DataFrame({'col1': [1, 2, 3]})
|
||||
mock_limit_dataset.return_value = (expected_data, {'col1': 0}, {'col1': 10})
|
||||
|
||||
result = preprocessor.adjust_limits(data)
|
||||
|
||||
mock_limit_dataset.assert_called_once()
|
||||
pd.testing.assert_frame_equal(result, expected_data)
|
||||
|
||||
|
||||
@patch('model_manager.sientia.models.create_features')
|
||||
def test_data_preprocessor_create_features(mock_create_features):
|
||||
"""Test create_features method."""
|
||||
preprocessor = DataPreprocessor(
|
||||
self_operations=['{var1}_{pow}_{2}'], cross_operations=['{var1}_{*}_{var2}']
|
||||
)
|
||||
data = pd.DataFrame({'var1': [1, 2, 3], 'var2': [2, 3, 4]})
|
||||
expected_data = pd.DataFrame({'var1': [1, 2, 3], 'var2': [2, 3, 4], 'var1_pow_2': [1, 4, 9]})
|
||||
mock_create_features.return_value = expected_data
|
||||
|
||||
result = preprocessor.create_features(data)
|
||||
|
||||
mock_create_features.assert_called_once()
|
||||
pd.testing.assert_frame_equal(result, expected_data)
|
||||
|
||||
|
||||
def test_data_preprocessor_create_ar():
|
||||
"""Test create_ar method."""
|
||||
preprocessor = DataPreprocessor(target_variable='target', ar_var='ar_target')
|
||||
data = pd.DataFrame({'target': [1, 2, 3, 4, 5]})
|
||||
|
||||
result = preprocessor.create_ar(data)
|
||||
|
||||
assert 'ar_target' in result.columns
|
||||
assert len(result) == 4
|
||||
|
||||
|
||||
def test_data_preprocessor_create_ar_without_ar_var():
|
||||
"""Test create_ar without ar_var."""
|
||||
preprocessor = DataPreprocessor(target_variable='target')
|
||||
data = pd.DataFrame({'target': [1, 2, 3, 4, 5]})
|
||||
|
||||
result = preprocessor.create_ar(data)
|
||||
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
def test_data_preprocessor_create_lags():
|
||||
"""Test create_lags method."""
|
||||
preprocessor = DataPreprocessor(created_lags={'var1': 1})
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
|
||||
result = preprocessor.create_lags(data)
|
||||
|
||||
assert 'var1_lag1' in result.columns
|
||||
assert len(result) == 4
|
||||
|
||||
|
||||
def test_data_preprocessor_create_lags_with_zero_lag():
|
||||
"""Test create_lags with zero lag."""
|
||||
preprocessor = DataPreprocessor(created_lags={'var1': 0})
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
|
||||
result = preprocessor.create_lags(data)
|
||||
|
||||
assert 'var1_lag0' not in result.columns
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
def test_data_preprocessor_create_lags_without_created_lags():
|
||||
"""Test create_lags without created_lags."""
|
||||
preprocessor = DataPreprocessor()
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
|
||||
result = preprocessor.create_lags(data)
|
||||
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
def test_data_preprocessor_create_lags_with_missing_column():
|
||||
"""Test create_lags with missing column."""
|
||||
preprocessor = DataPreprocessor(created_lags={'var2': 1})
|
||||
data = pd.DataFrame({'var1': [1, 2, 3, 4, 5]})
|
||||
|
||||
result = preprocessor.create_lags(data)
|
||||
|
||||
assert 'var2_lag1' not in result.columns
|
||||
|
||||
|
||||
def test_data_preprocessor_fit_with_x_and_y():
|
||||
"""Test fit method with x and y."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1', 'var2'],
|
||||
steps_order=['Discontinuity Treatment'],
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'var2': [2, 3, 4]})
|
||||
y = pd.Series([3, 5, 7], name='target')
|
||||
|
||||
result = preprocessor.fit(x, y)
|
||||
|
||||
assert result is preprocessor
|
||||
|
||||
|
||||
def test_data_preprocessor_fit_with_only_x():
|
||||
"""Test fit method with only x."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1', 'var2'],
|
||||
steps_order=['Discontinuity Treatment'],
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'var2': [2, 3, 4], 'target': [3, 5, 7]})
|
||||
|
||||
result = preprocessor.fit(x)
|
||||
|
||||
assert result is preprocessor
|
||||
|
||||
|
||||
def test_data_preprocessor_fit_without_data():
|
||||
"""Test fit method without data."""
|
||||
preprocessor = DataPreprocessor(target_variable='target', input_columns=['var1'])
|
||||
|
||||
with raises(ValueError, match='No data was provided'):
|
||||
preprocessor.fit(None, None)
|
||||
|
||||
|
||||
def test_data_preprocessor_fit_without_input_columns():
|
||||
"""Test fit method without input_columns."""
|
||||
preprocessor = DataPreprocessor(target_variable='target')
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
|
||||
with raises(AssertionError, match='input_columns must be set'):
|
||||
preprocessor.fit(x)
|
||||
|
||||
|
||||
def test_data_preprocessor_fit_with_normalization():
|
||||
"""Test fit method with normalization."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1', 'var2'],
|
||||
scaler_name='Standard Scaler',
|
||||
scaler_params={},
|
||||
steps_order=['Normalization'],
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'var2': [2, 3, 4], 'target': [3, 5, 7]})
|
||||
|
||||
result = preprocessor.fit(x)
|
||||
|
||||
assert result is preprocessor
|
||||
assert preprocessor.scaler_params is not None
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_with_timestamp():
|
||||
"""Test transform method with timestamp column."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target', input_columns=['var1'], steps_order=['Discontinuity Treatment']
|
||||
)
|
||||
x = pd.DataFrame({'timestamp': [1, 2, 3], 'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
|
||||
result = preprocessor.transform(x)
|
||||
|
||||
assert 'timestamp' not in result.columns
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_without_timestamp():
|
||||
"""Test transform method without timestamp column."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target', input_columns=['var1'], steps_order=['Discontinuity Treatment']
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
|
||||
result = preprocessor.transform(x)
|
||||
|
||||
assert 'var1' in result.columns
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_without_input_columns():
|
||||
"""Test transform method without input_columns."""
|
||||
preprocessor = DataPreprocessor(target_variable='target')
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
|
||||
with raises(AssertionError, match='input_columns must be set'):
|
||||
preprocessor.transform(x)
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_with_feature_creation():
|
||||
"""Test transform method with feature creation."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1'],
|
||||
self_operations=['{var1}_{pow}_{2}'],
|
||||
steps_order=['Feature Creation'],
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
|
||||
with patch('model_manager.sientia.models.create_features') as mock_create:
|
||||
mock_create.return_value = x
|
||||
preprocessor.transform(x)
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_with_lag_creation():
|
||||
"""Test transform method with lag creation."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1', 'ar_target'],
|
||||
ar_var='ar_target',
|
||||
created_lags={'var1': 1},
|
||||
steps_order=['Lag Creation'],
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3, 4, 5], 'target': [3, 5, 7, 9, 11]})
|
||||
|
||||
result = preprocessor.transform(x)
|
||||
|
||||
assert 'ar_target' in result.columns
|
||||
assert 'var1_lag1' in result.columns
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_with_normalization():
|
||||
"""Test transform method with normalization."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1'],
|
||||
scaler_name='Standard Scaler',
|
||||
scaler_params={},
|
||||
steps_order=['Normalization'],
|
||||
)
|
||||
train_x = pd.DataFrame({'var1': [1, 2, 3], 'target': [3, 5, 7]})
|
||||
preprocessor.fit(train_x)
|
||||
|
||||
test_x = pd.DataFrame({'var1': [4, 5, 6], 'target': [9, 11, 13]})
|
||||
result = preprocessor.transform(test_x)
|
||||
|
||||
assert 'var1' in result.columns
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_with_self_operations():
|
||||
"""Test get_required_columns with self_operations."""
|
||||
preprocessor = DataPreprocessor(self_operations=['{var1}_{pow}_{2}'])
|
||||
existing_columns = ['var1', 'var2']
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
assert 'var1' in result
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_with_cross_operations():
|
||||
"""Test get_required_columns with cross_operations."""
|
||||
preprocessor = DataPreprocessor(cross_operations=['{var1}_{*}_{var2}'])
|
||||
existing_columns = ['var1', 'var2']
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
assert 'var1' in result
|
||||
assert 'var2' in result
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_with_created_lags():
|
||||
"""Test get_required_columns with created_lags."""
|
||||
preprocessor = DataPreprocessor(created_lags={'var1': 1})
|
||||
existing_columns = ['var1', 'var2']
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
assert 'var1' in result
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_with_missing_columns():
|
||||
"""Test get_required_columns with missing columns in existing_columns."""
|
||||
preprocessor = DataPreprocessor(
|
||||
self_operations=['{var3}_{pow}_{2}'], cross_operations=['{var4}_{*}_{var5}']
|
||||
)
|
||||
existing_columns = ['var1', 'var2']
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
assert 'var3' in result
|
||||
assert 'var4' in result
|
||||
assert 'var5' in result
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_removes_duplicates():
|
||||
"""Test get_required_columns removes duplicates from self_operations."""
|
||||
preprocessor = DataPreprocessor(self_operations=['{var1}_{pow}_{2}'], created_lags={'var1': 1})
|
||||
existing_columns = ['var1', 'var2']
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
# var1 is in existing_columns, so it should not be in required_columns
|
||||
assert 'var1' not in result or result.count('var1') <= 1
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_removes_self_operations_branch():
|
||||
"""Ensure line 278 removes columns present in self_operations iterable."""
|
||||
preprocessor = DataPreprocessor(
|
||||
self_operations=_IterableWithContains(['{var1}_{pow}_{2}'], contains_values=['var1'])
|
||||
)
|
||||
existing_columns: list[str] = []
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
assert 'var1' not in result
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_removes_cross_operations_branch():
|
||||
"""Ensure line 285 removes columns present in cross_operations iterable."""
|
||||
preprocessor = DataPreprocessor(
|
||||
cross_operations=_IterableWithContains(['{var1}_{*}_{var2}'], contains_values=['var1'])
|
||||
)
|
||||
existing_columns: list[str] = []
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
assert 'var1' not in result
|
||||
assert 'var2' in result
|
||||
|
||||
|
||||
def test_data_preprocessor_get_required_columns_removes_created_lags():
|
||||
"""Test get_required_columns removes columns from created_lags when column is in created_lags dict - covers line 292."""
|
||||
preprocessor = DataPreprocessor(created_lags={'var1': 1, 'var2': 1})
|
||||
existing_columns = ['var3']
|
||||
|
||||
result = preprocessor.get_required_columns(existing_columns)
|
||||
|
||||
# var1 and var2 should be removed because they're in created_lags dict and not in existing_columns
|
||||
assert 'var1' not in result
|
||||
assert 'var2' not in result
|
||||
|
||||
|
||||
def test_data_preprocessor_fit_all_steps():
|
||||
"""Test fit method with all steps."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1'],
|
||||
nan_treatment='drop',
|
||||
lag_train={'var1': 1},
|
||||
static_threshold=3,
|
||||
low_lim={'var1': 0},
|
||||
upp_lim={'var1': 10},
|
||||
scaler_name='Standard Scaler',
|
||||
scaler_params={},
|
||||
)
|
||||
x = pd.DataFrame({'var1': [1, 2, 3, 4, 5], 'target': [3, 5, 7, 9, 11]})
|
||||
|
||||
with patch('model_manager.sientia.models.treat_nan') as mock_treat:
|
||||
with patch('model_manager.sientia.models.TimeSeriesDiscontinuityAnalyzer'):
|
||||
with patch('model_manager.sientia.models.limit_dataset') as mock_limit:
|
||||
mock_treat.return_value = x
|
||||
mock_limit.return_value = (x, {'var1': 0}, {'var1': 10})
|
||||
result = preprocessor.fit(x)
|
||||
|
||||
assert result is preprocessor
|
||||
|
||||
|
||||
def test_data_preprocessor_transform_all_steps():
|
||||
"""Test transform method with all steps."""
|
||||
preprocessor = DataPreprocessor(
|
||||
target_variable='target',
|
||||
input_columns=['var1'],
|
||||
nan_treatment='drop',
|
||||
lag_transform={'var1': 1},
|
||||
static_threshold=3,
|
||||
low_lim={'var1': 0},
|
||||
upp_lim={'var1': 10},
|
||||
scaler_name='Standard Scaler',
|
||||
scaler_params={},
|
||||
self_operations=['{var1}_{pow}_{2}'],
|
||||
ar_var='ar_target',
|
||||
created_lags={'var1': 1},
|
||||
)
|
||||
train_x = pd.DataFrame({'var1': [1, 2, 3, 4, 5], 'target': [3, 5, 7, 9, 11]})
|
||||
|
||||
with patch('model_manager.sientia.models.treat_nan') as mock_treat:
|
||||
with patch('model_manager.sientia.models.TimeSeriesDiscontinuityAnalyzer'):
|
||||
with patch('model_manager.sientia.models.limit_dataset') as mock_limit:
|
||||
with patch('model_manager.sientia.models.create_features') as mock_create:
|
||||
mock_treat.return_value = train_x
|
||||
mock_limit.return_value = (train_x, {'var1': 0}, {'var1': 10})
|
||||
mock_create.return_value = train_x
|
||||
preprocessor.fit(train_x)
|
||||
|
||||
test_x = pd.DataFrame({'var1': [6, 7, 8, 9, 10], 'target': [13, 15, 17, 19, 21]})
|
||||
|
||||
with patch('model_manager.sientia.models.treat_nan') as mock_treat:
|
||||
with patch('model_manager.sientia.models.TimeSeriesDiscontinuityAnalyzer'):
|
||||
with patch('model_manager.sientia.models.limit_dataset') as mock_limit:
|
||||
with patch('model_manager.sientia.models.create_features') as mock_create:
|
||||
mock_treat.return_value = test_x
|
||||
mock_limit.return_value = (test_x, {'var1': 0}, {'var1': 10})
|
||||
mock_create.return_value = test_x
|
||||
result = preprocessor.transform(test_x)
|
||||
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
361
tests/sientia/test_reports.py
Normal file
361
tests/sientia/test_reports.py
Normal file
@@ -0,0 +1,361 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from model_manager.sientia import reports
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_color_options(monkeypatch):
|
||||
def fake_color_options(**kwargs):
|
||||
return dict(kwargs)
|
||||
|
||||
monkeypatch.setattr(reports, 'ColorOptions', fake_color_options)
|
||||
|
||||
|
||||
def test_load_html_from_file_success(tmp_path):
|
||||
sample_file = tmp_path / 'sample.html'
|
||||
sample_file.write_text('<p>Hello</p>', encoding='utf-8')
|
||||
|
||||
content = reports.load_html_from_file(str(sample_file))
|
||||
|
||||
assert content == '<p>Hello</p>'
|
||||
|
||||
|
||||
def test_load_html_from_file_missing_file(capsys):
|
||||
result = reports.load_html_from_file('non-existent.html')
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result is None
|
||||
assert 'File not found: non-existent.html' in captured.out
|
||||
|
||||
|
||||
def test_load_html_from_file_os_error(monkeypatch, capsys):
|
||||
def fake_open(*_args, **_kwargs):
|
||||
raise OSError('boom')
|
||||
|
||||
monkeypatch.setattr('builtins.open', fake_open)
|
||||
|
||||
result = reports.load_html_from_file('path.html')
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result is None
|
||||
assert 'Error reading file: boom' in captured.out
|
||||
|
||||
|
||||
def test_inject_content_replaces_section():
|
||||
main_html = "<html><body><div id='target'>old</div></body></html>"
|
||||
content = '<span>new</span>'
|
||||
|
||||
result = reports.inject_content(main_html, 'target', content)
|
||||
|
||||
soup = BeautifulSoup(result, 'html.parser')
|
||||
section = soup.find(id='target')
|
||||
assert section is not None
|
||||
assert section.find('span').text == 'new'
|
||||
|
||||
|
||||
def test_inject_content_missing_section(capsys):
|
||||
main_html = "<html><body><div id='other'>keep</div></body></html>"
|
||||
|
||||
result = reports.inject_content(main_html, 'missing', '<p>ignored</p>')
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Section with id 'missing' not found" in captured.out
|
||||
soup = BeautifulSoup(result, 'html.parser')
|
||||
assert soup.find(id='other') is not None
|
||||
|
||||
|
||||
def test_reports_init_sets_defaults(stub_color_options):
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
|
||||
assert report.metrics == []
|
||||
assert isinstance(report.options, list) and len(report.options) == 1
|
||||
assert report.sections == {}
|
||||
assert report.base_path is None
|
||||
|
||||
|
||||
def test_add_data_quality_section_without_run(monkeypatch, stub_color_options):
|
||||
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: 'summary')
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
'generate_column_metrics',
|
||||
lambda *args, **kwargs: ('columns', kwargs),
|
||||
)
|
||||
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: 'conflict')
|
||||
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: 'correlations')
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
report.add_data_quality_section(columns=['col'], run=False)
|
||||
|
||||
assert report.metrics[-4:] == [
|
||||
'summary',
|
||||
('columns', {'columns': ['col'], 'skip_id_column': True}),
|
||||
'conflict',
|
||||
'correlations',
|
||||
]
|
||||
assert 'data_quality' not in report.sections
|
||||
|
||||
|
||||
def test_add_data_quality_section_with_run(monkeypatch, tmp_path, stub_color_options):
|
||||
summary = object()
|
||||
column_metrics = object()
|
||||
conflict = object()
|
||||
correlations = object()
|
||||
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary)
|
||||
|
||||
def fake_generate_column_metrics(*_args, **kwargs):
|
||||
return column_metrics
|
||||
|
||||
monkeypatch.setattr(reports, 'generate_column_metrics', fake_generate_column_metrics)
|
||||
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict)
|
||||
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations)
|
||||
|
||||
report_instance = MagicMock()
|
||||
report_instance.as_dict.return_value = {'result': 'data_quality'}
|
||||
ReportMock = MagicMock(return_value=report_instance)
|
||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path))
|
||||
report.add_data_quality_section(columns=['c1'], run=True)
|
||||
|
||||
assert report.metrics[-4:] == [summary, column_metrics, conflict, correlations]
|
||||
assert report.sections['data_quality'] == {'result': 'data_quality'}
|
||||
ReportMock.assert_called_once_with(
|
||||
metrics=[summary, column_metrics, conflict, correlations], options=report.options
|
||||
)
|
||||
report_instance.run.assert_called_once_with(reference_data='ref', current_data='cur')
|
||||
report_instance.save_html.assert_called_once_with(
|
||||
os.path.join(str(tmp_path), 'data_quality.html')
|
||||
)
|
||||
|
||||
|
||||
def test_add_data_quality_section_run_without_base_path(monkeypatch, stub_color_options):
|
||||
summary = object()
|
||||
column_metrics = object()
|
||||
conflict = object()
|
||||
correlations = object()
|
||||
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary)
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
'generate_column_metrics',
|
||||
lambda *args, **kwargs: column_metrics,
|
||||
)
|
||||
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict)
|
||||
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations)
|
||||
|
||||
report_instance = MagicMock()
|
||||
report_instance.as_dict.return_value = {'result': 'quality'}
|
||||
ReportMock = MagicMock(return_value=report_instance)
|
||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
report.add_data_quality_section(run=True)
|
||||
|
||||
assert report.sections['data_quality'] == {'result': 'quality'}
|
||||
report_instance.save_html.assert_not_called()
|
||||
|
||||
|
||||
def test_add_data_drift_section_paths(monkeypatch, tmp_path, stub_color_options):
|
||||
drift_instances = [object(), object(), object()]
|
||||
DataDriftPresetMock = MagicMock(side_effect=drift_instances)
|
||||
monkeypatch.setattr(reports, 'DataDriftPreset', DataDriftPresetMock)
|
||||
|
||||
report_instance = MagicMock()
|
||||
report_instance.as_dict.return_value = {'result': 'data_drift'}
|
||||
ReportMock = MagicMock(return_value=report_instance)
|
||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path))
|
||||
report.add_data_drift_section(columns=['c1'], run=False)
|
||||
assert report.metrics[-1] == drift_instances[0]
|
||||
assert 'data_drift' not in report.sections
|
||||
|
||||
report.add_data_drift_section(columns=['c1'], run=True)
|
||||
assert report.sections['data_drift'] == {'result': 'data_drift'}
|
||||
ReportMock.assert_called_with(metrics=[drift_instances[2]], options=report.options)
|
||||
report_instance.run.assert_called_with(reference_data='ref', current_data='cur')
|
||||
report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'data_drift.html'))
|
||||
|
||||
|
||||
def test_add_data_drift_section_run_without_base_path(monkeypatch, stub_color_options):
|
||||
drift_instances = [object(), object(), object()]
|
||||
DataDriftPresetMock = MagicMock(side_effect=drift_instances)
|
||||
monkeypatch.setattr(reports, 'DataDriftPreset', DataDriftPresetMock)
|
||||
|
||||
report_instance = MagicMock()
|
||||
report_instance.as_dict.return_value = {'result': 'drift'}
|
||||
ReportMock = MagicMock(return_value=report_instance)
|
||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
report.add_data_drift_section(run=True)
|
||||
|
||||
assert report.sections['data_drift'] == {'result': 'drift'}
|
||||
report_instance.save_html.assert_not_called()
|
||||
|
||||
|
||||
def test_add_regression_section(monkeypatch, tmp_path, stub_color_options):
|
||||
regression_metrics = [object() for _ in range(7)]
|
||||
monkeypatch.setattr(reports, 'RegressionPerformanceMetrics', lambda: regression_metrics[0])
|
||||
monkeypatch.setattr(reports, 'RegressionDummyMetric', lambda: regression_metrics[1])
|
||||
monkeypatch.setattr(
|
||||
reports, 'RegressionPredictedVsActualScatter', lambda: regression_metrics[2]
|
||||
)
|
||||
monkeypatch.setattr(reports, 'RegressionPredictedVsActualPlot', lambda: regression_metrics[3])
|
||||
monkeypatch.setattr(reports, 'RegressionErrorPlot', lambda: regression_metrics[4])
|
||||
monkeypatch.setattr(reports, 'RegressionAbsPercentageErrorPlot', lambda: regression_metrics[5])
|
||||
monkeypatch.setattr(reports, 'RegressionErrorDistribution', lambda: regression_metrics[6])
|
||||
|
||||
report_instance = MagicMock()
|
||||
report_instance.as_dict.return_value = {'result': 'regression'}
|
||||
ReportMock = MagicMock(return_value=report_instance)
|
||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path))
|
||||
|
||||
report.add_regression_section(run=False)
|
||||
assert report.metrics[-7:] == regression_metrics
|
||||
assert 'regression' not in report.sections
|
||||
|
||||
report.add_regression_section(run=True)
|
||||
assert report.sections['regression'] == {'result': 'regression'}
|
||||
ReportMock.assert_called_with(metrics=regression_metrics, options=report.options)
|
||||
report_instance.run.assert_called_with(reference_data='ref', current_data='cur')
|
||||
report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'regression.html'))
|
||||
|
||||
|
||||
def test_add_regression_section_run_without_base_path(monkeypatch, stub_color_options):
|
||||
regression_metrics = [object() for _ in range(7)]
|
||||
monkeypatch.setattr(reports, 'RegressionPerformanceMetrics', lambda: regression_metrics[0])
|
||||
monkeypatch.setattr(reports, 'RegressionDummyMetric', lambda: regression_metrics[1])
|
||||
monkeypatch.setattr(
|
||||
reports, 'RegressionPredictedVsActualScatter', lambda: regression_metrics[2]
|
||||
)
|
||||
monkeypatch.setattr(reports, 'RegressionPredictedVsActualPlot', lambda: regression_metrics[3])
|
||||
monkeypatch.setattr(reports, 'RegressionErrorPlot', lambda: regression_metrics[4])
|
||||
monkeypatch.setattr(reports, 'RegressionAbsPercentageErrorPlot', lambda: regression_metrics[5])
|
||||
monkeypatch.setattr(reports, 'RegressionErrorDistribution', lambda: regression_metrics[6])
|
||||
|
||||
report_instance = MagicMock()
|
||||
report_instance.as_dict.return_value = {'result': 'reg'}
|
||||
ReportMock = MagicMock(return_value=report_instance)
|
||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
report.add_regression_section(run=True)
|
||||
|
||||
assert report.sections['regression'] == {'result': 'reg'}
|
||||
report_instance.save_html.assert_not_called()
|
||||
|
||||
|
||||
def test_set_color_options_appends(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def color_options_mock(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return kwargs
|
||||
|
||||
monkeypatch.setattr(reports, 'ColorOptions', color_options_mock)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
report.set_color_options(primary_color='#111', secondary_color='#222')
|
||||
|
||||
assert len(report.options) == 2
|
||||
assert calls[0]['primary_color'] == '#0F4C81'
|
||||
assert calls[1]['primary_color'] == '#111'
|
||||
assert report.options[1]['secondary_color'] == '#222'
|
||||
|
||||
|
||||
def test_save_all_sections_html_requires_base_path(stub_color_options):
|
||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
report.save_all_sections_html('output/report.html')
|
||||
|
||||
|
||||
def test_save_all_sections_html_writes_output(tmp_path, stub_color_options):
|
||||
base_dir = tmp_path / 'templates'
|
||||
base_dir.mkdir()
|
||||
(base_dir / 'header.html').write_text(
|
||||
"<html><body><div id='data_drift'></div><div id='data_quality'></div><div id='regression'></div></body></html>",
|
||||
encoding='utf-8',
|
||||
)
|
||||
(base_dir / 'data_drift.html').write_text('<p>Drift</p>', encoding='utf-8')
|
||||
(base_dir / 'data_quality.html').write_text('<p>Quality</p>', encoding='utf-8')
|
||||
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir))
|
||||
output_path = tmp_path / 'reports' / 'combined.html'
|
||||
|
||||
report.save_all_sections_html(str(output_path))
|
||||
|
||||
assert output_path.exists()
|
||||
content = output_path.read_text(encoding='utf-8')
|
||||
assert '<p>Drift</p>' in content
|
||||
assert '<p>Quality</p>' in content
|
||||
assert '<p>Regression</p>' in content
|
||||
|
||||
|
||||
def test_save_all_sections_html_creates_directory(monkeypatch, tmp_path, stub_color_options):
|
||||
base_dir = tmp_path / 'templates'
|
||||
base_dir.mkdir()
|
||||
(base_dir / 'header.html').write_text(
|
||||
"<html><body><div id='data_drift'></div><div id='data_quality'></div><div id='regression'></div></body></html>",
|
||||
encoding='utf-8',
|
||||
)
|
||||
(base_dir / 'data_drift.html').write_text('<p>Drift</p>', encoding='utf-8')
|
||||
(base_dir / 'data_quality.html').write_text('<p>Quality</p>', encoding='utf-8')
|
||||
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||
|
||||
make_dirs_called = []
|
||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir))
|
||||
output_path = tmp_path / 'nested' / 'report.html'
|
||||
output_dir = str(output_path.parent)
|
||||
|
||||
original_exists = os.path.exists
|
||||
original_makedirs = os.makedirs
|
||||
|
||||
def fake_exists(path):
|
||||
if path == output_dir:
|
||||
return False
|
||||
return original_exists(path)
|
||||
|
||||
def fake_makedirs(path, exist_ok=False):
|
||||
make_dirs_called.append((path, exist_ok))
|
||||
return original_makedirs(path, exist_ok=exist_ok)
|
||||
|
||||
monkeypatch.setattr(os.path, 'exists', fake_exists)
|
||||
monkeypatch.setattr(os, 'makedirs', fake_makedirs)
|
||||
|
||||
report.save_all_sections_html(str(output_path))
|
||||
|
||||
assert make_dirs_called == [(str(output_path.parent), True)]
|
||||
|
||||
|
||||
def test_save_all_sections_html_no_directory_needed(monkeypatch, tmp_path, stub_color_options):
|
||||
base_dir = tmp_path / 'templates'
|
||||
base_dir.mkdir()
|
||||
(base_dir / 'header.html').write_text(
|
||||
"<html><body><div id='data_drift'></div><div id='data_quality'></div><div id='regression'></div></body></html>",
|
||||
encoding='utf-8',
|
||||
)
|
||||
(base_dir / 'data_drift.html').write_text('<p>Drift</p>', encoding='utf-8')
|
||||
(base_dir / 'data_quality.html').write_text('<p>Quality</p>', encoding='utf-8')
|
||||
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||
|
||||
mk_calls = []
|
||||
|
||||
def fake_makedirs(path, exist_ok=False):
|
||||
mk_calls.append((path, exist_ok))
|
||||
|
||||
monkeypatch.setattr(os, 'makedirs', fake_makedirs)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir))
|
||||
report.save_all_sections_html('report.html')
|
||||
|
||||
assert mk_calls == []
|
||||
assert (tmp_path / 'report.html').exists()
|
||||
59
tests/sientia/test_utils.py
Normal file
59
tests/sientia/test_utils.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from model_manager.sientia import utils
|
||||
|
||||
|
||||
def test_split_train_test_default(monkeypatch):
|
||||
captured_args = {}
|
||||
|
||||
def fake_train_test_split(*args, **kwargs):
|
||||
captured_args['args'] = args
|
||||
captured_args['kwargs'] = kwargs
|
||||
return ('X_train', 'X_test', 'y_train', 'y_test')
|
||||
|
||||
monkeypatch.setattr(utils, 'train_test_split', fake_train_test_split)
|
||||
|
||||
X = [1, 2, 3, 4]
|
||||
y = [0, 1, 0, 1]
|
||||
|
||||
result = utils.split_train_test(X, y)
|
||||
|
||||
assert captured_args['args'] == (X, y)
|
||||
assert captured_args['kwargs'] == {
|
||||
'test_size': None,
|
||||
'train_size': None,
|
||||
'random_state': None,
|
||||
'shuffle': True,
|
||||
'stratify': None,
|
||||
}
|
||||
assert result == ('X_train', 'X_test', 'y_train', 'y_test')
|
||||
|
||||
|
||||
def test_split_train_test_with_parameters(monkeypatch):
|
||||
captured_kwargs = {}
|
||||
|
||||
def fake_train_test_split(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return ('train_X', 'test_X', 'train_y', 'test_y')
|
||||
|
||||
monkeypatch.setattr(utils, 'train_test_split', fake_train_test_split)
|
||||
|
||||
X = [[1], [2], [3], [4]]
|
||||
y = [0, 1, 0, 1]
|
||||
|
||||
result = utils.split_train_test(
|
||||
X,
|
||||
y,
|
||||
test_size=0.25,
|
||||
train_size=0.75,
|
||||
random_state=42,
|
||||
shuffle=False,
|
||||
stratify=y,
|
||||
)
|
||||
|
||||
assert captured_kwargs == {
|
||||
'test_size': 0.25,
|
||||
'train_size': 0.75,
|
||||
'random_state': 42,
|
||||
'shuffle': False,
|
||||
'stratify': y,
|
||||
}
|
||||
assert result == ('train_X', 'test_X', 'train_y', 'test_y')
|
||||
@@ -1,37 +0,0 @@
|
||||
from pandas import DataFrame
|
||||
|
||||
from model_manager.utils.filters.conditional_filters import (
|
||||
filter_empty_data,
|
||||
filter_specific_variables_null_values,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_specific_variables_null_values():
|
||||
assert (
|
||||
filter_specific_variables_null_values(
|
||||
DataFrame({'variable': ['variable1', 'variable2'], 'value': [1, 2]}),
|
||||
config={'variables': ['variable2']},
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_filter_specific_variables_null_values_with_null_values():
|
||||
assert (
|
||||
filter_specific_variables_null_values(
|
||||
DataFrame({'variable': ['variable1', 'variable2'], 'value': [1, None]}),
|
||||
config={'variables': ['variable2']},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_filter_empty_data():
|
||||
assert filter_empty_data(DataFrame(), {}) is True
|
||||
|
||||
|
||||
def test_filter_empty_data_with_data():
|
||||
assert (
|
||||
filter_empty_data(DataFrame({'variable': ['variable1', 'variable2'], 'value': [1, 2]}), {})
|
||||
is False
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
from pandas import DataFrame
|
||||
|
||||
from model_manager.utils.filters.mlflow_filters import api_error_filter, nan_values_filter
|
||||
|
||||
|
||||
def test_api_error_filter_invalid_response():
|
||||
assert api_error_filter(None, {})
|
||||
|
||||
|
||||
def test_api_error_filter_valid_response_fail():
|
||||
assert api_error_filter({'success': False}, {})
|
||||
|
||||
|
||||
def test_api_error_filter_valid_response_success():
|
||||
assert not api_error_filter({'success': True}, {})
|
||||
|
||||
|
||||
def test_nan_values_filter_all_nan_values():
|
||||
assert nan_values_filter(DataFrame({'variable': [None, None]}), {})
|
||||
|
||||
|
||||
def test_nan_values_filter_no_nan_values():
|
||||
assert not nan_values_filter(DataFrame({'variable': [1, 2]}), {})
|
||||
@@ -1,9 +1,8 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import ANY, MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pandas import DataFrame, Timestamp
|
||||
from pandas import DataFrame
|
||||
|
||||
from model_manager.utils.repository.model_repository import MLFlowRepository
|
||||
|
||||
@@ -32,477 +31,10 @@ metadata = {
|
||||
}
|
||||
|
||||
|
||||
class Any:
|
||||
pass
|
||||
|
||||
|
||||
invalid_cases = [
|
||||
({'value': {'2024-01-01 12:00:00': 1, 2024: 2}}),
|
||||
({'value': {'2024-01-01': 1, '2024-01-02': 2}}),
|
||||
({'value': {Any(): 1, Any(): 2}}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('data', invalid_cases)
|
||||
def test_detect_and_parse_datetime_index_error_cases(mlflow_repository, data):
|
||||
input_data = DataFrame(data)
|
||||
|
||||
with pytest.raises(ValueError) as e:
|
||||
mlflow_repository.detect_and_parse_datetime_index(input_data, metadata['metadata'])
|
||||
|
||||
assert (
|
||||
str(e)
|
||||
== 'Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format %Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
|
||||
valid_cases = [
|
||||
(
|
||||
{'value': {'2024-01-01 12:00:00+0000': 1, '2024-01-02 12:00:00+0000': 2}},
|
||||
['2024-01-01 12:00:00+0000', '2024-01-02 12:00:00+0000'],
|
||||
),
|
||||
(
|
||||
{
|
||||
'value': {
|
||||
datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC): 1,
|
||||
datetime(2025, 1, 2, 12, 0, 0, tzinfo=UTC): 2,
|
||||
}
|
||||
},
|
||||
['2025-01-01 12:00:00+0000', '2025-01-02 12:00:00+0000'],
|
||||
),
|
||||
(
|
||||
{
|
||||
'value': {
|
||||
Timestamp(2026, 1, 1, 12, 0, 0, tzinfo=UTC): 1,
|
||||
Timestamp(2026, 1, 2, 12, 0, 0, tzinfo=UTC): 2,
|
||||
}
|
||||
},
|
||||
['2026-01-01 12:00:00+0000', '2026-01-02 12:00:00+0000'],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('data,expected', valid_cases)
|
||||
def test_detect_and_parse_datetime_index_valid_format(mlflow_repository, data, expected):
|
||||
input_data = DataFrame(data)
|
||||
|
||||
response = mlflow_repository.detect_and_parse_datetime_index(input_data, metadata['metadata'])
|
||||
|
||||
assert response.index.tolist() == expected
|
||||
|
||||
|
||||
def test_transform_success(mlflow_repository):
|
||||
data = MagicMock()
|
||||
model_name = 'model'
|
||||
|
||||
mlflow_repository.detect_and_parse_datetime_index = MagicMock()
|
||||
|
||||
output = mlflow_repository.transform(model_name, data, {}, metadata['metadata'])
|
||||
|
||||
mlflow_repository.model_serving.get_cached_transform.assert_called_once_with(
|
||||
model_name, data, 0, 'sklearn', False, 'model', 'predict'
|
||||
)
|
||||
|
||||
mlflow_repository.detect_and_parse_datetime_index.assert_called_once_with(
|
||||
mlflow_repository.model_serving.get_cached_transform.return_value, metadata['metadata']
|
||||
)
|
||||
|
||||
assert output == {
|
||||
'success': True,
|
||||
'content': mlflow_repository.detect_and_parse_datetime_index.return_value.to_dict.return_value,
|
||||
}
|
||||
|
||||
|
||||
def test_transform_error(mlflow_repository):
|
||||
data = MagicMock()
|
||||
model_name = 'model'
|
||||
|
||||
mlflow_repository.model_serving.get_cached_transform.side_effect = Exception('error')
|
||||
|
||||
output = mlflow_repository.transform(model_name, data, {}, metadata['metadata'])
|
||||
|
||||
mlflow_repository.model_serving.get_cached_transform.assert_called_once_with(
|
||||
model_name, data, 0, 'sklearn', False, 'model', 'predict'
|
||||
)
|
||||
|
||||
assert output == {'success': False, 'content': {'message': 'error', 'traceback': ANY}}
|
||||
|
||||
|
||||
def test_predict_success(mlflow_repository):
|
||||
data = DataFrame({'feat_1': {'index_1': 2, 'index_2': 3}})
|
||||
model_name = 'model'
|
||||
mlflow_repository.model_serving.get_cached_predict.return_value = np.array([2, 3])
|
||||
|
||||
output = mlflow_repository.predict(model_name, data, {}, metadata['metadata'])
|
||||
|
||||
mlflow_repository.model_serving.get_cached_predict.assert_called_once_with(
|
||||
model_name, data, 0, 'pyfunc', False, 'model'
|
||||
)
|
||||
|
||||
assert output['success'] is True
|
||||
assert output['content'] == {
|
||||
'prediction': {'index_1': 2, 'index_2': 3},
|
||||
'response_time': {'index_1': ANY, 'index_2': ANY},
|
||||
}
|
||||
|
||||
|
||||
def test_predict_error(mlflow_repository):
|
||||
data = DataFrame({'feat_1': {'index_1': 2, 'index_2': 3}})
|
||||
model_name = 'model'
|
||||
|
||||
mlflow_repository.model_serving.get_cached_predict = MagicMock(side_effect=Exception('error'))
|
||||
|
||||
output = mlflow_repository.predict(model_name, data, {}, metadata['metadata'])
|
||||
|
||||
mlflow_repository.model_serving.get_cached_predict.assert_called_once_with(
|
||||
model_name, data, 0, 'pyfunc', False, 'model'
|
||||
)
|
||||
|
||||
assert output == {'success': False, 'content': {'message': 'error', 'traceback': ANY}}
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_get_experiment_by_run_id(mlflow, mlflow_repository):
|
||||
mlflow.get_run.return_value = MagicMock(
|
||||
info=MagicMock(
|
||||
experiment_id='0',
|
||||
)
|
||||
)
|
||||
mlflow.get_experiment.return_value = MagicMock()
|
||||
mlflow.get_experiment.return_value.name = 'test'
|
||||
|
||||
output = mlflow_repository.get_experiment_by_run_id('0')
|
||||
assert output == 'test'
|
||||
mlflow.get_run.assert_called_once_with('0')
|
||||
mlflow.get_experiment.assert_called_once_with('0')
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_get_next_run_name(mlflow, mlflow_repository):
|
||||
mlflow.search_runs.return_value = [1, 2, 3]
|
||||
output = mlflow_repository.get_next_run_name('run')
|
||||
assert output == 'run-4'
|
||||
mlflow.search_runs.assert_called_once_with(
|
||||
experiment_names=['run'],
|
||||
order_by=['start_time desc'],
|
||||
)
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_get_experiment_success(mlflow, mlflow_repository):
|
||||
mlflow.get_experiment_by_name.return_value = MagicMock(experiment_id='0')
|
||||
|
||||
output = mlflow_repository.get_experiment('test')
|
||||
|
||||
assert output == '0'
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_get_experiment_error(mlflow, mlflow_repository):
|
||||
mlflow.get_experiment_by_name.return_value = None
|
||||
|
||||
try:
|
||||
mlflow_repository.get_experiment('test')
|
||||
except ValueError as e:
|
||||
assert str(e) == 'Experiment test not found'
|
||||
else:
|
||||
raise AssertionError('Expected exception')
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_get_experiment_last_run(mlflow, mlflow_repository):
|
||||
mlflow.search_runs.return_value = DataFrame(
|
||||
{
|
||||
'params.retrain': ['True', 'False', 'True', 'False'],
|
||||
'end_time': ['2021-01-01', '2021-01-02', '2021-01-03', '2021-01-04'],
|
||||
'run_id': ['0', '1', '2', '3'],
|
||||
}
|
||||
)
|
||||
|
||||
output = mlflow_repository.get_experiment_last_run(0)
|
||||
|
||||
mlflow.search_runs.assert_called_once_with(
|
||||
experiment_ids=[0],
|
||||
filter_string='',
|
||||
output_format='pandas',
|
||||
)
|
||||
|
||||
assert output == '2'
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_get_experiment_last_run_error(mlflow, mlflow_repository):
|
||||
mlflow.search_runs.return_value = []
|
||||
|
||||
try:
|
||||
mlflow_repository.get_experiment_last_run(0)
|
||||
except ValueError as e:
|
||||
assert str(e) == 'Runs is not a pandas DataFrame'
|
||||
else:
|
||||
raise AssertionError('Expected exception')
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.sklearn')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.set_experiment')
|
||||
def test_create_model_experiment(set_experiment, sklearn, mlflow_repository):
|
||||
mlflow_repository.model_serving.get_model_info = MagicMock(return_value='0')
|
||||
mlflow_repository.model_serving.get_model_uri = MagicMock(return_value='test')
|
||||
mlflow_repository.get_experiment_by_run_id = MagicMock()
|
||||
|
||||
data_model_mock = MagicMock()
|
||||
prediction_model_mock = MagicMock()
|
||||
|
||||
sklearn.load_model.side_effect = [data_model_mock, prediction_model_mock]
|
||||
|
||||
data_model_mock.fit.return_value = data_model_mock
|
||||
data_model_mock.predict.return_value = DataFrame(
|
||||
{
|
||||
'x': [10, 20, 30],
|
||||
}
|
||||
)
|
||||
data_model_mock.target_variable = 'y'
|
||||
|
||||
prediction_model_mock.fit.return_value = prediction_model_mock
|
||||
|
||||
data = DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})
|
||||
|
||||
output = mlflow_repository.create_model_experiment('test', data)
|
||||
|
||||
mlflow_repository.model_serving.get_model_info.assert_called_once_with('test')
|
||||
mlflow_repository.model_serving.get_model_uri.assert_called_once_with('0', prediction=False)
|
||||
|
||||
sklearn.load_model.assert_has_calls(
|
||||
[
|
||||
call(mlflow_repository.model_serving.get_model_uri.return_value),
|
||||
call('models:/test/production'),
|
||||
]
|
||||
)
|
||||
assert sklearn.load_model.call_count == 2
|
||||
|
||||
data_model_mock.fit.assert_called_once_with(data)
|
||||
data_model_mock.predict.assert_called_once_with(data)
|
||||
|
||||
fit_args = prediction_model_mock.fit.call_args[0][0]
|
||||
assert fit_args.equals(
|
||||
DataFrame(
|
||||
{
|
||||
'x': [10, 20, 30],
|
||||
'y': [4, 5, 6],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
mlflow_repository.get_experiment_by_run_id.assert_called_once_with('0')
|
||||
|
||||
set_experiment.assert_called_once_with(mlflow_repository.get_experiment_by_run_id.return_value)
|
||||
|
||||
assert output == (
|
||||
prediction_model_mock,
|
||||
data_model_mock,
|
||||
mlflow_repository.get_experiment_by_run_id.return_value,
|
||||
)
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.path.exists')
|
||||
@patch('model_manager.utils.repository.model_repository.remove')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.start_run')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.log_param')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.sklearn.log_model')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.log_artifact')
|
||||
def test_perform_model_retrain(
|
||||
log_artifact, log_model, log_param, start_run, mock_remove, mock_path_exists, mlflow_repository
|
||||
):
|
||||
# Create mock models with attributes to test the for loops (lines 268-274)
|
||||
prediction_model_mock = MagicMock()
|
||||
prediction_model_mock.__dict__ = {'model': 'pred_model', 'param1': 'value1', 'param2': 'value2'}
|
||||
|
||||
data_model_mock = MagicMock()
|
||||
data_model_mock.__dict__ = {'model': 'data_model', 'param3': 'value3', 'param4': 'value4'}
|
||||
|
||||
experiment = 'test'
|
||||
model_name = 'test'
|
||||
data = MagicMock()
|
||||
|
||||
mlflow_repository.get_next_run_name = MagicMock(return_value='test-1')
|
||||
run = MagicMock()
|
||||
start_run.__enter__.return_value = run
|
||||
mock_path_exists.return_value = True
|
||||
|
||||
output = mlflow_repository.perform_model_retrain(
|
||||
prediction_model_mock, data_model_mock, experiment, model_name, data
|
||||
)
|
||||
|
||||
mlflow_repository.get_next_run_name.assert_called_once_with(experiment)
|
||||
start_run.assert_called_once_with(
|
||||
run_name='test-1', description='Retrain model test with new data'
|
||||
)
|
||||
|
||||
log_model.assert_has_calls(
|
||||
[
|
||||
call(data_model_mock, 'data_model'),
|
||||
call(prediction_model_mock, 'prediction_model'),
|
||||
]
|
||||
)
|
||||
|
||||
data.to_csv.assert_called_once_with('temp/raw_data_test.csv', index=True)
|
||||
|
||||
log_artifact.assert_called_once_with('temp/raw_data_test.csv')
|
||||
|
||||
# Verify that model attributes were logged (excluding 'model' key)
|
||||
log_param.assert_has_calls(
|
||||
[
|
||||
call('param1', 'value1'), # from prediction_model
|
||||
call('param2', 'value2'), # from prediction_model
|
||||
call('param3', 'value3'), # from data_model
|
||||
call('param4', 'value4'), # from data_model
|
||||
call('retrain', True),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
|
||||
# Verify temp file cleanup
|
||||
mock_path_exists.assert_called_once_with('temp/raw_data_test.csv')
|
||||
mock_remove.assert_called_once_with('temp/raw_data_test.csv')
|
||||
|
||||
assert output == ('Model retrained successfully', experiment)
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.path.exists')
|
||||
@patch('model_manager.utils.repository.model_repository.remove')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.start_run')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.log_param')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.sklearn.log_model')
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow.log_artifact')
|
||||
def test_perform_model_retrain_file_not_exists(
|
||||
log_artifact, log_model, log_param, start_run, mock_remove, mock_path_exists, mlflow_repository
|
||||
):
|
||||
"""Test perform_model_retrain when temp file doesn't exist (line 291->294 branch)."""
|
||||
prediction_model_mock = MagicMock()
|
||||
prediction_model_mock.__dict__ = {'model': 'pred_model'}
|
||||
|
||||
data_model_mock = MagicMock()
|
||||
data_model_mock.__dict__ = {'model': 'data_model'}
|
||||
|
||||
experiment = 'test'
|
||||
model_name = 'test'
|
||||
data = MagicMock()
|
||||
|
||||
mlflow_repository.get_next_run_name = MagicMock(return_value='test-1')
|
||||
run = MagicMock()
|
||||
start_run.__enter__.return_value = run
|
||||
mock_path_exists.return_value = False # File doesn't exist
|
||||
|
||||
output = mlflow_repository.perform_model_retrain(
|
||||
prediction_model_mock, data_model_mock, experiment, model_name, data
|
||||
)
|
||||
|
||||
# Verify temp file cleanup was checked but not executed
|
||||
mock_path_exists.assert_called_once_with('temp/raw_data_test.csv')
|
||||
mock_remove.assert_not_called() # Should not be called when file doesn't exist
|
||||
|
||||
assert output == ('Model retrained successfully', experiment)
|
||||
|
||||
|
||||
def test_retrain_model(mlflow_repository):
|
||||
data = MagicMock()
|
||||
model_name = 'test'
|
||||
|
||||
mlflow_repository.create_model_experiment = MagicMock(
|
||||
return_value=('data_model', 'prediction_model', '0')
|
||||
)
|
||||
|
||||
mlflow_repository.perform_model_retrain = MagicMock(return_value='Model retrained successfully')
|
||||
|
||||
output = mlflow_repository.retrain_model(data, model_name)
|
||||
|
||||
mlflow_repository.create_model_experiment.assert_called_once_with(model_name, data)
|
||||
|
||||
mlflow_repository.perform_model_retrain.assert_called_once_with(
|
||||
'data_model', 'prediction_model', '0', model_name, data
|
||||
)
|
||||
|
||||
assert output == 'Model retrained successfully'
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_update_production_model_by_run_id(mlflow, mlflow_repository):
|
||||
client_mock = MagicMock()
|
||||
mlflow.tracking.MlflowClient.return_value = client_mock
|
||||
|
||||
client_mock.get_registered_model.return_value = MagicMock(
|
||||
latest_versions=[
|
||||
MagicMock(version='1'),
|
||||
MagicMock(version='2'),
|
||||
MagicMock(version='3'),
|
||||
]
|
||||
)
|
||||
output = mlflow_repository.update_production_model_by_run_id('0', 'test')
|
||||
|
||||
mlflow.register_model.assert_called_once_with(
|
||||
'runs:/0/prediction_model',
|
||||
'test',
|
||||
)
|
||||
|
||||
mlflow.tracking.MlflowClient.assert_called_once()
|
||||
client_mock.get_registered_model.assert_called_once_with('test')
|
||||
client_mock.transition_model_version_stage.assert_called_once_with(
|
||||
name='test',
|
||||
version='3',
|
||||
stage='Production',
|
||||
archive_existing_versions=True,
|
||||
)
|
||||
|
||||
assert output == {
|
||||
'model_name': 'test',
|
||||
'version': '3',
|
||||
'mlflow_run_id': '0',
|
||||
}
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.mlflow')
|
||||
def test_update_production_model_by_run_id_error(mlflow, mlflow_repository):
|
||||
mlflow.tracking.MlflowClient.return_value = MagicMock(
|
||||
get_registered_model=MagicMock(return_value=MagicMock(latest_versions={}))
|
||||
)
|
||||
|
||||
try:
|
||||
mlflow_repository.update_production_model_by_run_id('0', 'test')
|
||||
except Exception as e: # noqa: BLE001
|
||||
assert str(e) == 'Model versions is not a list'
|
||||
else:
|
||||
raise AssertionError('Expected exception')
|
||||
|
||||
|
||||
def test_update_production_model(mlflow_repository):
|
||||
connector = mlflow_repository
|
||||
|
||||
with patch.object(connector, 'get_experiment', return_value='0') as get_experiment:
|
||||
with patch.object(
|
||||
connector, 'get_experiment_last_run', return_value='2'
|
||||
) as get_experiment_last_run:
|
||||
with patch.object(
|
||||
connector,
|
||||
'update_production_model_by_run_id',
|
||||
return_value={'model_name': 'test', 'version': '3', 'mlflow_run_id': '0'},
|
||||
) as update_production_model_by_run_id:
|
||||
output = connector.update_production_model('0', 'test')
|
||||
|
||||
get_experiment.assert_called_once_with('0')
|
||||
get_experiment_last_run.assert_called_once_with('0')
|
||||
update_production_model_by_run_id.assert_called_once_with('2', 'test')
|
||||
|
||||
assert output == {
|
||||
'model_name': 'test',
|
||||
'version': '3',
|
||||
'mlflow_run_id': '0',
|
||||
'mlflow_experiment_id': '0',
|
||||
}
|
||||
|
||||
|
||||
# ========== Tests for Model Artifact Generation Methods ==========
|
||||
|
||||
|
||||
def test_get_next_run_name_new(mlflow_repository):
|
||||
def test_get_next_run_name(mlflow_repository):
|
||||
"""Test get_next_run_name generates correct run name based on existing runs."""
|
||||
mlflow_repository.model_serving.search_runs_by_name.return_value = [
|
||||
MagicMock(),
|
||||
@@ -510,7 +42,7 @@ def test_get_next_run_name_new(mlflow_repository):
|
||||
MagicMock(),
|
||||
]
|
||||
|
||||
result = mlflow_repository.get_next_run_name_new('test_experiment')
|
||||
result = mlflow_repository.get_next_run_name('test_experiment')
|
||||
|
||||
mlflow_repository.model_serving.search_runs_by_name.assert_called_once_with(
|
||||
experiment_names=['test_experiment'], order_by=['start_time desc']
|
||||
@@ -518,13 +50,13 @@ def test_get_next_run_name_new(mlflow_repository):
|
||||
assert result == 'test_experiment-4'
|
||||
|
||||
|
||||
def test_get_next_run_name_new_first_run(mlflow_repository):
|
||||
def test_get_next_run_name_first_run(mlflow_repository):
|
||||
"""Test get_next_run_name for first run (no existing runs)."""
|
||||
mlflow_repository.model_serving.search_runs_by_name.return_value = []
|
||||
|
||||
result = mlflow_repository.get_next_run_name_new('new_experiment')
|
||||
result = mlflow_repository.get_next_run_name('test_experiment')
|
||||
|
||||
assert result == 'new_experiment-1'
|
||||
assert result == 'test_experiment-1'
|
||||
|
||||
|
||||
@patch('model_manager.utils.repository.model_repository.path')
|
||||
|
||||
@@ -125,7 +125,7 @@ async def test_main_success(
|
||||
namespace='test-namespace',
|
||||
runtime=ANY,
|
||||
)
|
||||
assert mock_worker.call_count == 2 # Two workers created
|
||||
assert mock_worker.call_count == 1 # Only one worker created
|
||||
mock_gather.assert_called_once()
|
||||
mock_sys_exit.assert_called_once_with(1)
|
||||
|
||||
@@ -201,7 +201,7 @@ async def test_main_exception_handling(
|
||||
@patch('model_manager.worker.worker.NotificationHandler')
|
||||
@patch('model_manager.worker.worker.start_prometheus_server')
|
||||
@patch('model_manager.worker.worker.get_logger')
|
||||
async def test_main_creates_two_workers(
|
||||
async def test_main_creates_only_one_worker(
|
||||
mock_get_logger,
|
||||
mock_start_prometheus,
|
||||
mock_notification_handler,
|
||||
@@ -213,7 +213,7 @@ async def test_main_creates_two_workers(
|
||||
mock_sys_exit,
|
||||
mock_env_vars,
|
||||
):
|
||||
"""Test that main creates two workers with correct configurations."""
|
||||
"""Test that main creates only one worker with correct configurations."""
|
||||
# Arrange
|
||||
mock_logger = MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
@@ -240,18 +240,13 @@ async def test_main_creates_two_workers(
|
||||
# Act
|
||||
await main()
|
||||
|
||||
# Assert - Verify two workers were created
|
||||
assert mock_worker.call_count == 2
|
||||
# Assert - Verify only one worker was created
|
||||
assert mock_worker.call_count == 1
|
||||
|
||||
# Verify first worker (minimal_retrain-queue)
|
||||
# Verify worker (train_model-queue)
|
||||
first_call = mock_worker.call_args_list[0]
|
||||
assert first_call[1]['task_queue'] == 'minimal_retrain-queue'
|
||||
assert 'MinimalRetrain' in str(first_call[1]['workflows'])
|
||||
|
||||
# Verify second worker (predictions_batch-queue)
|
||||
second_call = mock_worker.call_args_list[1]
|
||||
assert second_call[1]['task_queue'] == 'predictions_batch-queue'
|
||||
assert 'PredictionsBatch' in str(second_call[1]['workflows'])
|
||||
assert first_call[1]['task_queue'] == 'train_model-queue'
|
||||
assert 'TrainModel' in str(first_call[1]['workflows'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
from unittest.mock import ANY, AsyncMock, call, patch
|
||||
|
||||
from pytest import fixture, mark
|
||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
from model_manager.workflows.sub_workflows.format_and_export_prediction import (
|
||||
FormatAndExportPrediction,
|
||||
)
|
||||
|
||||
|
||||
@fixture
|
||||
def format_and_export_prediction():
|
||||
return FormatAndExportPrediction()
|
||||
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'model_id': 'test_model',
|
||||
'model_name': 'test_model',
|
||||
'workflow_name': 'test_workflow',
|
||||
'schema_name': 'test_schedule',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch(
|
||||
'model_manager.workflows.sub_workflows.format_and_export_prediction.workflow',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_run_none_path_flag(workflow_mock, format_and_export_prediction):
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'path_flag': None,
|
||||
'data': {'test': 'data'},
|
||||
'timestamp': '2021-01-01',
|
||||
'model_id': 1,
|
||||
'prediction_confidence': 0,
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'prediction_store_policy': 'erl:1',
|
||||
}
|
||||
|
||||
await format_and_export_prediction.run(input_data)
|
||||
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.format_prediction,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
'timestamp': input_data['timestamp'],
|
||||
'model_id': input_data['model_id'],
|
||||
'prediction_confidence': input_data['prediction_confidence'],
|
||||
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
workflow_mock.execute_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.export_data_to_postgres,
|
||||
{
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'data': workflow_mock.execute_local_activity_method.return_value,
|
||||
**metadata,
|
||||
'timestamp_conversion': {
|
||||
'column': 'timestamp',
|
||||
'format': DATETIME_FORMAT_WITH_TZ,
|
||||
},
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert workflow_mock.execute_activity_method.call_count == 2
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 1
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch(
|
||||
'model_manager.workflows.sub_workflows.format_and_export_prediction.workflow',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_run_default_path_flag(workflow_mock, format_and_export_prediction):
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'path_flag': 'default',
|
||||
'data': {'test': 'data'},
|
||||
'timestamp': '2021-01-01',
|
||||
'model_id': 1,
|
||||
'prediction_confidence': 0,
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'comment': 'test_comment',
|
||||
}
|
||||
|
||||
await format_and_export_prediction.run(input_data)
|
||||
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.format_default_prediction,
|
||||
{
|
||||
'timestamp': input_data['timestamp'],
|
||||
'model_id': input_data['model_id'],
|
||||
'prediction_confidence': input_data['prediction_confidence'],
|
||||
'comment': input_data['comment'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
workflow_mock.execute_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.export_data_to_postgres,
|
||||
{
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'data': workflow_mock.execute_local_activity_method.return_value,
|
||||
**metadata,
|
||||
'timestamp_conversion': {
|
||||
'column': 'timestamp',
|
||||
'format': DATETIME_FORMAT_WITH_TZ,
|
||||
},
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert workflow_mock.execute_activity_method.call_count == 2
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 1
|
||||
@@ -1,780 +0,0 @@
|
||||
from unittest.mock import ANY, AsyncMock, call, patch
|
||||
|
||||
from pytest import fixture, mark
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
from model_manager.workflows.sub_workflows.prediction_process import PredictionProcess
|
||||
|
||||
|
||||
@fixture
|
||||
def prediction_process():
|
||||
return PredictionProcess()
|
||||
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'model_id': 'test_model',
|
||||
'model_name': 'test_model',
|
||||
'workflow_name': 'test_workflow',
|
||||
'schema_name': 'test_schedule',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_run(workflow_mock, prediction_process):
|
||||
prediction_process.path_flag_handler = AsyncMock(return_value=False)
|
||||
# Arrange
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'data': {'test': 'data'},
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'model_id': 1,
|
||||
'input_filters': {'test': 'filter'},
|
||||
'mlflow_transform_filters': {'test': 'filter'},
|
||||
'mlflow_predict_filters': {'test': 'filter'},
|
||||
'model_name': 'test_model_name',
|
||||
'model_config': {'retention': '30'},
|
||||
'path_priority': ['continue', 'repeat', 'stop'],
|
||||
'prediction_store_policy': 'lts:1',
|
||||
}
|
||||
|
||||
# Mock the activity responses
|
||||
workflow_mock.execute_local_activity_method.side_effect = [
|
||||
'2024-01-01', # get_last_timestamp
|
||||
('continue', 0.95, 'Input data with bad quality'), # input_gate
|
||||
{'content': 'transformed_data', 'timestamp': '2024-01-01'}, # transform_data
|
||||
# mlflow_response_gate (transform)
|
||||
('continue', 0.95, 'Error'),
|
||||
# mlflow_content_gate (transform)
|
||||
('continue', 0.95, 'Transformed data not passed the content filter'),
|
||||
{'content': 'predicted_data', 'timestamp': '2024-01-01'}, # request_predict
|
||||
# mlflow_response_gate (predict)
|
||||
('continue', 0.95, 'Error'),
|
||||
]
|
||||
|
||||
# Act
|
||||
await prediction_process.run(input_data)
|
||||
|
||||
# Assert
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 7
|
||||
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.get_last_timestamp,
|
||||
{
|
||||
**metadata,
|
||||
'data': input_data['data'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.input_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['input_filters'],
|
||||
'data': input_data['data'],
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.request_transform,
|
||||
{
|
||||
**metadata,
|
||||
'data': input_data['data'],
|
||||
'model_name': input_data['model_name'],
|
||||
'model_config': input_data['model_config'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'},
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_content_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': 'transformed_data',
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.request_predict,
|
||||
{
|
||||
**metadata,
|
||||
'data': 'transformed_data',
|
||||
'model_name': input_data['model_name'],
|
||||
'model_config': input_data['model_config'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
**metadata,
|
||||
'filters': input_data['mlflow_predict_filters'],
|
||||
'data': {'content': 'predicted_data', 'timestamp': '2024-01-01'},
|
||||
'type': 'predict',
|
||||
'path_priority': input_data['path_priority'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
workflow_mock.execute_child_workflow.assert_called_once_with(
|
||||
'format_and_export_prediction',
|
||||
{
|
||||
'metadata': metadata,
|
||||
'path_flag': 'continue',
|
||||
'data': 'predicted_data',
|
||||
'prediction_confidence': 0.95,
|
||||
'timestamp': '2024-01-01',
|
||||
'model_id': 1,
|
||||
'model_name': 'test_model_name',
|
||||
'model_config': input_data['model_config'],
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'comment': 'Error',
|
||||
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_run_stop_at_input_gate(workflow_mock, prediction_process):
|
||||
prediction_process.path_flag_handler = AsyncMock(return_value=True)
|
||||
# Arrange
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'data': {'test': 'data'},
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'model_id': 1,
|
||||
'input_filters': {'test': 'filter'},
|
||||
'mlflow_transform_filters': {'test': 'filter'},
|
||||
'mlflow_predict_filters': {'test': 'filter'},
|
||||
'model_name': 'test_model_name',
|
||||
'model_config': {'retention': '30'},
|
||||
'path_priority': ['continue', 'repeat', 'stop'],
|
||||
}
|
||||
|
||||
# Mock the activity responses
|
||||
workflow_mock.execute_local_activity_method.side_effect = [
|
||||
'2024-01-01', # get_last_timestamp
|
||||
('stop', 0.95, 'Input data with bad quality'), # input_gate
|
||||
]
|
||||
|
||||
# Act
|
||||
await prediction_process.run(input_data)
|
||||
|
||||
# Assert
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 2
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.get_last_timestamp,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
),
|
||||
call(
|
||||
Activities.input_gate,
|
||||
{
|
||||
'filters': input_data['input_filters'],
|
||||
'data': input_data['data'],
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
),
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_run_stop_at_first_mlflow_response_gate(workflow_mock, prediction_process):
|
||||
prediction_process.path_flag_handler = AsyncMock(side_effect=[False, True])
|
||||
# Arrange
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'data': {'test': 'data'},
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'model_id': 1,
|
||||
'input_filters': {'test': 'filter'},
|
||||
'mlflow_transform_filters': {'test': 'filter'},
|
||||
'mlflow_predict_filters': {'test': 'filter'},
|
||||
'model_name': 'test_model_name',
|
||||
'model_config': {'retention': '30'},
|
||||
'path_priority': ['continue', 'repeat', 'stop'],
|
||||
}
|
||||
|
||||
# Mock the activity responses
|
||||
workflow_mock.execute_local_activity_method.side_effect = [
|
||||
'2024-01-01', # get_last_timestamp
|
||||
('repeat', 0.95, 'Input data with bad quality'), # input_gate
|
||||
{'content': 'transformed_data', 'timestamp': '2024-01-01'}, # transform_data
|
||||
('continue', 0.95, 'Error'), # mlflow_response_gate (transform)
|
||||
]
|
||||
|
||||
# Act
|
||||
await prediction_process.run(input_data)
|
||||
|
||||
# Assert
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 4
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.get_last_timestamp,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.input_gate,
|
||||
{
|
||||
'filters': input_data['input_filters'],
|
||||
'data': input_data['data'],
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.request_transform,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
'model_name': input_data['model_name'],
|
||||
'model_config': input_data['model_config'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'},
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_run_stop_at_mlflow_content_gate(workflow_mock, prediction_process):
|
||||
prediction_process.path_flag_handler = AsyncMock(side_effect=[False, False, True])
|
||||
# Arrange
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'data': {'test': 'data'},
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'model_id': 1,
|
||||
'input_filters': {'test': 'filter'},
|
||||
'mlflow_transform_filters': {'test': 'filter'},
|
||||
'mlflow_predict_filters': {'test': 'filter'},
|
||||
'model_name': 'test_model_name',
|
||||
'model_config': {'retention': '30'},
|
||||
'path_priority': ['continue', 'repeat', 'stop'],
|
||||
}
|
||||
|
||||
# Mock the activity responses
|
||||
workflow_mock.execute_local_activity_method.side_effect = [
|
||||
'2024-01-01', # get_last_timestamp
|
||||
('continue', 0.95, 'Input data with bad quality'), # input_gate
|
||||
{'content': 'transformed_data', 'timestamp': '2024-01-01'}, # transform_data
|
||||
# mlflow_response_gate (transform)
|
||||
('continue', 0.95, 'Error'),
|
||||
# mlflow_content_gate (transform)
|
||||
('continue', 0.95, 'Transformed data not passed the content filter'),
|
||||
]
|
||||
|
||||
# Act
|
||||
await prediction_process.run(input_data)
|
||||
|
||||
# Assert
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 5
|
||||
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.get_last_timestamp,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.input_gate,
|
||||
{
|
||||
'filters': input_data['input_filters'],
|
||||
'data': input_data['data'],
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.request_transform,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
'model_name': input_data['model_name'],
|
||||
'model_config': input_data['model_config'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'},
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_content_gate,
|
||||
{
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': 'transformed_data',
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_run_stop_at_mlflow_last_response_gate(workflow_mock, prediction_process):
|
||||
prediction_process.path_flag_handler = AsyncMock(side_effect=[False, False, False, True])
|
||||
# Arrange
|
||||
input_data = {
|
||||
'metadata': metadata,
|
||||
'data': {'test': 'data'},
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'model_id': 1,
|
||||
'input_filters': {'test': 'filter'},
|
||||
'mlflow_transform_filters': {'test': 'filter'},
|
||||
'mlflow_predict_filters': {'test': 'filter'},
|
||||
'model_name': 'test_model_name',
|
||||
'model_config': {'retention': '30'},
|
||||
'path_priority': ['continue', 'repeat', 'stop'],
|
||||
}
|
||||
|
||||
# Mock the activity responses
|
||||
workflow_mock.execute_local_activity_method.side_effect = [
|
||||
'2024-01-01', # get_last_timestamp
|
||||
('continue', 0.95, 'Input data with bad quality'), # input_gate
|
||||
{'content': 'transformed_data', 'timestamp': '2024-01-01'}, # transform_data
|
||||
# mlflow_response_gate (transform)
|
||||
('continue', 0.95, 'Error'),
|
||||
# mlflow_content_gate (transform)
|
||||
('continue', 0.95, 'Transformed data not passed the content filter'),
|
||||
{'content': 'predicted_data', 'timestamp': '2024-01-01'}, # request_predict
|
||||
('continue', 0.95, 'Error'), # mlflow_response_gate (predict)
|
||||
]
|
||||
|
||||
# Act
|
||||
await prediction_process.run(input_data)
|
||||
|
||||
# Assert
|
||||
assert workflow_mock.execute_local_activity_method.call_count == 7
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.get_last_timestamp,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.input_gate,
|
||||
{
|
||||
'filters': input_data['input_filters'],
|
||||
'data': input_data['data'],
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.request_transform,
|
||||
{
|
||||
'data': input_data['data'],
|
||||
'model_name': input_data['model_name'],
|
||||
'model_config': input_data['model_config'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'},
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_content_gate,
|
||||
{
|
||||
'filters': input_data['mlflow_transform_filters'],
|
||||
'data': 'transformed_data',
|
||||
'type': 'transform',
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.request_predict,
|
||||
{
|
||||
'data': 'transformed_data',
|
||||
'model_name': input_data['model_name'],
|
||||
'model_config': input_data['model_config'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.mlflow_response_gate,
|
||||
{
|
||||
'filters': input_data['mlflow_predict_filters'],
|
||||
'data': {'content': 'predicted_data', 'timestamp': '2024-01-01'},
|
||||
'type': 'predict',
|
||||
'path_priority': input_data['path_priority'],
|
||||
**metadata,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_path_flag_handler_stop(workflow_mock, prediction_process):
|
||||
# Arrange
|
||||
data = {'test': 'data'}
|
||||
path_flag = 'STOP'
|
||||
confidence = 0.95
|
||||
schema = 'test_schema'
|
||||
table_name = 'test_table'
|
||||
model = 'test_model'
|
||||
last_timestamp = '2024-01-01'
|
||||
model_name = 'test_model_name'
|
||||
model_config = {'retention': '30'}
|
||||
|
||||
# Act
|
||||
result = await prediction_process.path_flag_handler(
|
||||
data,
|
||||
path_flag,
|
||||
{
|
||||
'metadata': metadata,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'model_id': model,
|
||||
'last_timestamp': last_timestamp,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
},
|
||||
confidence,
|
||||
last_timestamp,
|
||||
'',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
workflow_mock.execute_local_activity_method.assert_not_called()
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_path_flag_handler_repeat(workflow_mock, prediction_process):
|
||||
# Arrange
|
||||
data = {'test': 'data'}
|
||||
path_flag = 'repeat'
|
||||
confidence = 0.95
|
||||
schema = 'test_schema'
|
||||
table_name = 'test_table'
|
||||
model = 'test_model'
|
||||
last_timestamp = '2024-01-01'
|
||||
model_name = 'test_model_name'
|
||||
model_config = {'retention': '30'}
|
||||
|
||||
# Act
|
||||
result = await prediction_process.path_flag_handler(
|
||||
data,
|
||||
path_flag,
|
||||
{
|
||||
'metadata': metadata,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'model_id': model,
|
||||
'last_timestamp': last_timestamp,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
},
|
||||
confidence,
|
||||
last_timestamp,
|
||||
'',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
workflow_mock.execute_activity_method.assert_called_once_with(
|
||||
Activities.repeat_last_prediction,
|
||||
{
|
||||
**metadata,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'model': model,
|
||||
'last_timestamp': last_timestamp,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_path_flag_handler_continue(workflow_mock, prediction_process):
|
||||
# Arrange
|
||||
data = {'test': 'data'}
|
||||
path_flag = 'CONTINUE'
|
||||
confidence = 0.95
|
||||
schema = 'test_schema'
|
||||
table_name = 'test_table'
|
||||
model = 'test_model'
|
||||
last_timestamp = '2024-01-01'
|
||||
model_name = 'test_model_name'
|
||||
model_config = {'retention': '30'}
|
||||
prediction_store_policy = 'erl:1'
|
||||
|
||||
# Act
|
||||
result = await prediction_process.path_flag_handler(
|
||||
data,
|
||||
path_flag,
|
||||
{
|
||||
'metadata': metadata,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'model_id': model,
|
||||
'last_timestamp': last_timestamp,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
'prediction_store_policy': prediction_store_policy,
|
||||
},
|
||||
confidence,
|
||||
last_timestamp,
|
||||
'Prediction Process',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
workflow_mock.execute_activity_method.assert_not_called()
|
||||
workflow_mock.execute_child_workflow.assert_called_once_with(
|
||||
'format_and_export_prediction',
|
||||
{
|
||||
'metadata': metadata,
|
||||
'path_flag': path_flag,
|
||||
'data': data,
|
||||
'prediction_confidence': confidence,
|
||||
'timestamp': last_timestamp,
|
||||
'model_id': model,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'comment': 'Prediction Process',
|
||||
'prediction_store_policy': prediction_store_policy,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock)
|
||||
async def test_path_flag_handler_unknown(workflow_mock, prediction_process):
|
||||
# Arrange
|
||||
data = {'test': 'data'}
|
||||
path_flag = 'unknown'
|
||||
confidence = 0.95
|
||||
schema = 'test_schema'
|
||||
table_name = 'test_table'
|
||||
model = 'test_model'
|
||||
last_timestamp = '2024-01-01'
|
||||
model_name = 'test_model_name'
|
||||
model_config = {'retention': '30'}
|
||||
prediction_store_policy = 'erl:1'
|
||||
# Act
|
||||
result = await prediction_process.path_flag_handler(
|
||||
data,
|
||||
path_flag,
|
||||
{
|
||||
**metadata,
|
||||
'schema': schema,
|
||||
'table_name': table_name,
|
||||
'model_id': model,
|
||||
'last_timestamp': last_timestamp,
|
||||
'model_name': model_name,
|
||||
'model_config': model_config,
|
||||
'prediction_store_policy': prediction_store_policy,
|
||||
},
|
||||
confidence,
|
||||
last_timestamp,
|
||||
'',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
workflow_mock.execute_activity_method.assert_not_called()
|
||||
workflow_mock.execute_child_workflow.assert_not_called()
|
||||
@@ -1,106 +0,0 @@
|
||||
from unittest.mock import ANY, AsyncMock, call, patch
|
||||
|
||||
from pytest import fixture, mark
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
from model_manager.workflows.minimal_retrain import MinimalRetrain
|
||||
|
||||
|
||||
@fixture
|
||||
def minimal_retrain() -> MinimalRetrain:
|
||||
return MinimalRetrain()
|
||||
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'model_id': 'test_model_id',
|
||||
'model_name': 'test_model',
|
||||
'workflow_name': 'minimal_retrain',
|
||||
'schedule_name': 'test_schedule',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.minimal_retrain.workflow', new_callable=AsyncMock)
|
||||
async def test_run(workflow_mock: AsyncMock, minimal_retrain: MinimalRetrain):
|
||||
input_data = {
|
||||
'model_id': 'test_model_id',
|
||||
'model_name': 'test_model',
|
||||
'workflow_name': 'minimal_retrain',
|
||||
'schedule_name': 'test_schedule',
|
||||
'query': 'test_query',
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
}
|
||||
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
return_value={
|
||||
'data1': '1',
|
||||
'data2': '2',
|
||||
}
|
||||
)
|
||||
|
||||
await minimal_retrain.run(input_data)
|
||||
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.load_custom_query,
|
||||
{
|
||||
**metadata,
|
||||
'query': input_data['query'],
|
||||
'datetime_columns': input_data.get('datetime_columns', []),
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
workflow_mock.execute_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.retrain_model,
|
||||
{
|
||||
**metadata,
|
||||
'data': workflow_mock.execute_local_activity_method.return_value,
|
||||
'model_name': input_data['model_name'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
workflow_mock.execute_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.update_production_model,
|
||||
{
|
||||
**metadata,
|
||||
'model_name': input_data['model_name'],
|
||||
'model_id': input_data['model_id'],
|
||||
**workflow_mock.execute_activity_method.return_value,
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
workflow_mock.execute_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.export_data_to_postgres,
|
||||
{
|
||||
**metadata,
|
||||
'data': workflow_mock.execute_activity_method.return_value,
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
from unittest.mock import ANY, AsyncMock, call, patch
|
||||
|
||||
from pytest import fixture, mark
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
from model_manager.workflows.predictions_batch import PredictionsBatch
|
||||
|
||||
|
||||
@fixture
|
||||
def predictions_batch() -> PredictionsBatch:
|
||||
return PredictionsBatch()
|
||||
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
'model_id': 'test_model_id',
|
||||
'model_name': 'test_model',
|
||||
'workflow_name': 'predictions_batch',
|
||||
'schedule_name': 'test_schedule',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
@patch('model_manager.workflows.predictions_batch.workflow', new_callable=AsyncMock)
|
||||
async def test_run(workflow_mock: AsyncMock, predictions_batch: PredictionsBatch):
|
||||
workflow_mock.execute_local_activity_method.return_value = {'data': 'test_data'}
|
||||
input_data = {
|
||||
'schedule_name': 'test_schedule',
|
||||
'model_name': 'test_model',
|
||||
'model_id': 'test_model_id',
|
||||
'query': 'SELECT * FROM test',
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'datetime_columns': ['timestamp', 'created_at'],
|
||||
'prediction_store_policy': 'erl:1',
|
||||
'model_config': {'retention': '30'},
|
||||
}
|
||||
|
||||
await predictions_batch.run(input_data)
|
||||
|
||||
workflow_mock.execute_local_activity_method.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
Activities.load_custom_query,
|
||||
{
|
||||
**metadata,
|
||||
'query': input_data['query'],
|
||||
'datetime_columns': input_data.get('datetime_columns', []),
|
||||
},
|
||||
retry_policy=ANY,
|
||||
start_to_close_timeout=ANY,
|
||||
)
|
||||
]
|
||||
)
|
||||
prediction_input = {
|
||||
'metadata': metadata,
|
||||
'data': {'data': 'test_data'},
|
||||
'schema': input_data['schema'],
|
||||
'table_name': input_data['table_name'],
|
||||
'model_id': input_data['model_id'],
|
||||
'model_name': input_data['model_name'],
|
||||
'input_filters': input_data.get('input_filters', {'EMPTY_DATA': {'POLICY': 'STOP'}}),
|
||||
'mlflow_transform_filters': input_data.get(
|
||||
'mlflow_transform_filters', {'API_ERROR': {'POLICY': 'STOP'}}
|
||||
),
|
||||
'mlflow_predict_filters': input_data.get(
|
||||
'mlflow_predict_filters', {'API_ERROR': {'POLICY': 'STOP'}}
|
||||
),
|
||||
'model_config': input_data.get('model_config', {}),
|
||||
'path_priority': input_data.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT']),
|
||||
'prediction_store_policy': input_data.get('prediction_store_policy', 'erl:1'),
|
||||
}
|
||||
|
||||
workflow_mock.execute_child_workflow.assert_has_calls(
|
||||
[call('prediction_process', prediction_input)]
|
||||
)
|
||||
@@ -89,6 +89,9 @@ async def test_run_success_complete_flow(
|
||||
'removed_intervals': [],
|
||||
}
|
||||
|
||||
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
|
||||
workflow_mock.logger = MagicMock()
|
||||
|
||||
# Mock activity responses
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
@@ -264,6 +267,9 @@ async def test_validate_training_parameters_validation_error(
|
||||
input_data = {'experiment_run_id': 123}
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
|
||||
workflow_mock.logger = MagicMock()
|
||||
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
ValueError('Missing required field'), # validate_train_params fails
|
||||
@@ -318,6 +324,9 @@ async def test_download_and_train_model_download_error(
|
||||
"""Test download error is handled correctly."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
|
||||
workflow_mock.logger = MagicMock()
|
||||
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
Exception('MinIO connection failed'), # fetch_file_from_minio fails
|
||||
@@ -342,6 +351,9 @@ async def test_download_and_train_model_training_error(
|
||||
"""Test training error is handled correctly."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
# Mock workflow.logger to avoid RuntimeWarning about unawaited coroutines
|
||||
workflow_mock.logger = MagicMock()
|
||||
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
b'file_content', # fetch_file_from_minio succeeds
|
||||
@@ -396,6 +408,7 @@ async def test_download_and_train_model_closes_bytesio_on_error(
|
||||
"""Test that BytesIO is closed in finally block even on error."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
workflow_mock.logger = MagicMock()
|
||||
# Create a mock BytesIO with close method
|
||||
mock_file = MagicMock()
|
||||
mock_file.close = MagicMock()
|
||||
@@ -425,6 +438,7 @@ async def test_download_and_train_model_handles_file_without_close(
|
||||
"""Test that workflow handles file objects without close method gracefully."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
workflow_mock.logger = MagicMock()
|
||||
# Create a mock file without close method
|
||||
mock_file = MagicMock(spec=[])
|
||||
|
||||
@@ -457,6 +471,7 @@ async def test_save_model_to_mlflow_success(
|
||||
"""Test successful model saving to MLFlow."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
workflow_mock.logger = MagicMock()
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
mock_train_result, # save_model
|
||||
@@ -480,6 +495,7 @@ async def test_save_model_to_mlflow_error(
|
||||
"""Test MLFlow save error is handled correctly."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
workflow_mock.logger = MagicMock()
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
Exception('MLFlow connection failed'), # save_model fails
|
||||
@@ -511,6 +527,7 @@ async def test_cleanup_resources_success(
|
||||
"""Test successful resource cleanup."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
workflow_mock.logger = MagicMock()
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
None, # cleanup_run_directory
|
||||
@@ -537,6 +554,7 @@ async def test_cleanup_resources_delete_error(
|
||||
"""Test cleanup handles delete errors correctly."""
|
||||
metadata = {'metadata': {'experiment_run_id': 123, 'workflow_name': 'train_model'}}
|
||||
|
||||
workflow_mock.logger = MagicMock()
|
||||
workflow_mock.execute_activity_method = AsyncMock(
|
||||
side_effect=[
|
||||
None, # cleanup_run_directory succeeds
|
||||
|
||||
Reference in New Issue
Block a user