feat: log regression metrics as parameters in Training class
- Added a method to persist computed regression metrics (MSE, MAE, R²) as MLflow parameters during model training, enhancing model evaluation and tracking. - Updated the Training class to log the equation path if available, improving artifact management.
This commit is contained in:
417
docs/train-model-workflow-io-diff-main-vs-current-branch.md
Normal file
417
docs/train-model-workflow-io-diff-main-vs-current-branch.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Train Model Workflow IO Diff (`main` vs current branch)
|
||||||
|
|
||||||
|
Base comparison: `git diff main...HEAD`
|
||||||
|
Workflow analyzed: `train_model`
|
||||||
|
|
||||||
|
## 1) Executive overview
|
||||||
|
|
||||||
|
This branch introduces a structural refactor of the training stack and a contract update for workflow input/output.
|
||||||
|
|
||||||
|
Main impacts:
|
||||||
|
|
||||||
|
- The old in-house training stack (`TrainingRepository`, `ModelRepository`, `StorageRepository`, `model_manager.sientia.models`) was replaced by:
|
||||||
|
- `DataManagerRepository` (data prep + metrics + report generation)
|
||||||
|
- `SientiaModel` wrapper from plugin store (`sientia_model`)
|
||||||
|
- `SientiaMLflowRepository` (MLflow integration)
|
||||||
|
- `MinioRepository` (storage integration)
|
||||||
|
- Input contract moved from many fixed legacy ML params to a plugin/wrapper-oriented schema (`model_type`, `*_kwargs`, `model_metadata`, optional `val_file_name`).
|
||||||
|
- Workflow return changed from `None` to a serializable result object (`dict[str, Any] | None`) containing training execution metadata.
|
||||||
|
- Queue naming and worker bootstrap architecture now depend on runtime (`train_model-<runtime>-queue`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Input contract diff (before vs now)
|
||||||
|
|
||||||
|
### 2.1 Previous contract (`main`)
|
||||||
|
|
||||||
|
`TrainModelParams` in `main` required a large set of explicit fields for the old preprocessing/model pipeline, focused only in linear regression model:
|
||||||
|
|
||||||
|
- Core:
|
||||||
|
- `experiment_run_id`, `variable_columns`, `target_variable`
|
||||||
|
- `bucket_name`, `file_name`, `line_separator`, `decimal_separator`
|
||||||
|
- `train_size`, `shuffle`
|
||||||
|
- Legacy preprocessing/model fields focused only in linear regression model (required in `from_dict`):
|
||||||
|
- `lag_train`, `lag_val`
|
||||||
|
- `rem_static_win`, `low_lim`, `upp_lim`, `window`
|
||||||
|
- `use_scaler`, `include_ar`, `scaler_name`
|
||||||
|
- `removed_intervals`, `start_date`, `end_date`, `nan_treatment`
|
||||||
|
- `degree`, `interaction_only`
|
||||||
|
- `experiment_name`, `model_name`
|
||||||
|
- `support_filters` (optional dict), `static_threshold` (optional int)
|
||||||
|
|
||||||
|
Validation was strongly tied to this structure (lag ranges, limits consistency, polynomial/scaler constraints, etc.).
|
||||||
|
|
||||||
|
### 2.2 Current contract (this branch)
|
||||||
|
|
||||||
|
`TrainModelParams` now supports a plugin-driven schema and wrapper kwargs:
|
||||||
|
|
||||||
|
- Kept/mandatory core fields:
|
||||||
|
- `experiment_run_id` (now accepts numeric string too; coerced to int)
|
||||||
|
- `variable_columns`, `target_variable`
|
||||||
|
- `bucket_name`, `file_name`, `line_separator`, `decimal_separator`
|
||||||
|
- `train_size`, `shuffle`
|
||||||
|
- `model_name`
|
||||||
|
- `model_type`
|
||||||
|
- `data_model_kwargs`, `model_kwargs`, `opt_params` (required as dict by current `from_dict`)
|
||||||
|
- New/updated fields:
|
||||||
|
- `random_state` (default `42`)
|
||||||
|
- `val_file_name` (optional explicit validation file)
|
||||||
|
- `model_id` (currently optional, but needs discussion, since the model metadata in MongoDB should be created before the model training)
|
||||||
|
- Removed from required input contract:
|
||||||
|
- `lag_train`, `lag_val`, `rem_static_win`, `low_lim`, `upp_lim`, `window`
|
||||||
|
- `use_scaler`, `include_ar`
|
||||||
|
- `degree`, `interaction_only`, `nan_treatment`
|
||||||
|
- `start_date`, `end_date`, `scaler_name`
|
||||||
|
- `removed_intervals`, `support_filters`, `static_threshold`
|
||||||
|
- Parameters internally derived:
|
||||||
|
- `model_metadata` model type info from plugin store.
|
||||||
|
- `run_name` is internally derived from experiment name and datetime.
|
||||||
|
- `experiment_name` is internally derived from `model_name`.
|
||||||
|
|
||||||
|
### 2.3 Validation behavior changes
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Validation was mostly hardcoded business checks tied to legacy linear/polynomial stack.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Validation still checks core constraints (`train_size`, non-empty strings, etc.), but model-specific validation moved to JSON Schema driven checks, using OpenAPI/JSON Schema definitions from plugin store:
|
||||||
|
- `model_metadata.schemas.components.schemas.data_model`
|
||||||
|
- `model_metadata.schemas.components.schemas.model`
|
||||||
|
- `model_metadata.schemas.components.schemas.opt_params`
|
||||||
|
- `model_metadata` is now a required semantic dependency for `validate_business_rules()`.
|
||||||
|
- Date format validation remains, but allowed formats are defined locally in `train_model_params.py`.
|
||||||
|
|
||||||
|
### 2.4 Input loading pipeline changes in workflow
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- `validate_train_params` directly consumed workflow input.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
1. `load_model_metadata` runs first (fetches model index/schema from plugin store and injects `model_metadata`).
|
||||||
|
2. `validate_train_params` runs with enriched payload.
|
||||||
|
|
||||||
|
This means IO preprocessing now depends on plugin-store metadata resolution before final validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Output contract diff (before vs now)
|
||||||
|
|
||||||
|
### 3.1 Workflow return (`train_model.run`)
|
||||||
|
|
||||||
|
Before (`main`):
|
||||||
|
- Return type: `None`
|
||||||
|
- Workflow side effects were persisted mainly via DB status updates and MLflow artifacts.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Return type: `dict[str, Any] | None`
|
||||||
|
- Workflow returns the training activity summary when successful.
|
||||||
|
|
||||||
|
### 3.2 Activity-level training result payload
|
||||||
|
|
||||||
|
Before (from `Training.train_model` in `main` path):
|
||||||
|
- Returned minimal dict:
|
||||||
|
- `run_name`
|
||||||
|
- `run_dir`
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Returns extended dict:
|
||||||
|
- `run_name`
|
||||||
|
- `experiment_name`
|
||||||
|
- `run_id`
|
||||||
|
- `run_dir`
|
||||||
|
|
||||||
|
### 3.3 Persistence map by destination (DB, MLflow, MinIO, local filesystem)
|
||||||
|
|
||||||
|
This section maps where each artifact/metadata goes, in which format, and how that changed from `main`.
|
||||||
|
|
||||||
|
#### 3.3.1 PostgreSQL (`experiment_run` table)
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Update path: `update_experiment_run` activity with `UpdateType.MODEL_SAVED`.
|
||||||
|
- Persisted on success:
|
||||||
|
- `status` transition to `TRAINING_SUCCESS`
|
||||||
|
- `run_name` (MLflow run identifier used by current implementation)
|
||||||
|
- Persisted on failures:
|
||||||
|
- `status` transition to validation/training error statuses
|
||||||
|
- `error_message`
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Same update path and status/error behavior.
|
||||||
|
- Even though train activity now returns more metadata (`run_id`, `experiment_name`), current workflow update for `MODEL_SAVED` still forwards mainly `run_name`.
|
||||||
|
- Practical effect:
|
||||||
|
- DB remains status-centric and run-name-centric
|
||||||
|
- richer identifiers exist in workflow return payload, not fully mirrored to DB columns in current flow
|
||||||
|
|
||||||
|
#### 3.3.2 MLflow (tracking server/artifact store)
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Persistence orchestration lived in `ModelRepository.save_model()` + `_save_run()`.
|
||||||
|
- Typical persisted content:
|
||||||
|
- model params (many legacy params such as lags, limits, scaler config, removed intervals)
|
||||||
|
- regression metrics (`MSE`, `R2`, `MAE`)
|
||||||
|
- model objects:
|
||||||
|
- `data_model`
|
||||||
|
- `prediction_model`
|
||||||
|
- artifacts:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- optional `model_equation.json`
|
||||||
|
- Run naming:
|
||||||
|
- computed by querying existing runs and appending sequence (`<experiment>-<n>` style)
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Persistence orchestrated in `Training._persist_training_artifacts()` and MLflow run context is opened by `SientiaMLflowRepository.start_run(...)`.
|
||||||
|
- Persisted content now:
|
||||||
|
- model wrapper itself via `wrapper.store_model(name=train_params.model_name)`
|
||||||
|
- regression metrics also logged as MLflow params via `mlflow.log_param(...)`:
|
||||||
|
- `mse_val`
|
||||||
|
- `mae_val`
|
||||||
|
- `r2_val`
|
||||||
|
- artifacts explicitly logged with `mlflow.log_artifact(...)`:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- metrics are computed before save (`mse_val`, `mae_val`, `r2_val`) and persisted in the run as params
|
||||||
|
- Run identifiers now exposed back to workflow:
|
||||||
|
- `experiment_name`
|
||||||
|
- `run_name`
|
||||||
|
- `run_id`
|
||||||
|
- Notable behavioral change:
|
||||||
|
- `wrapper._input_example` is cleared (`None`) before storing model.
|
||||||
|
|
||||||
|
#### 3.3.3 MinIO object storage
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Read path:
|
||||||
|
- single source object downloaded via `StorageRepository.fetch_file(bucket_name, file_name)`
|
||||||
|
- Write path:
|
||||||
|
- training workflow did not write generated outputs to MinIO in this code path
|
||||||
|
- generated artifacts were persisted to MLflow, not uploaded back to MinIO
|
||||||
|
- Location:
|
||||||
|
- source data in input bucket/key provided by workflow input (`bucket_name` + `file_name`)
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Read path migrated to `MinioRepository.download_file(...)`.
|
||||||
|
- Supports two input objects:
|
||||||
|
- mandatory training object: `bucket_name` + `file_name`
|
||||||
|
- optional validation object: same `bucket_name` + `val_file_name`
|
||||||
|
- Write path:
|
||||||
|
- still no artifact upload to MinIO in this workflow path
|
||||||
|
- report/CSV outputs continue to flow to MLflow artifacts
|
||||||
|
- Location details:
|
||||||
|
- bucket resolved from payload (`bucket_name`)
|
||||||
|
- object key exactly from payload (`file_name`, optional `val_file_name`)
|
||||||
|
- default bucket in env/config is `MINIO_DEFAULT_BUCKET`, but runtime payload can override via `bucket_name`
|
||||||
|
|
||||||
|
#### 3.3.4 Local filesystem (ephemeral runtime workspace)
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Temporary run dir created under reports root using run name + timestamp suffix.
|
||||||
|
- Artifacts generated locally in that directory:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- optional `model_equation.json`
|
||||||
|
- After MLflow logging, cleanup activity removed temp directory.
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Temporary run dir managed by `DataManagerRepository` under runtime reports root (`.../reports/temp/<run_name>`).
|
||||||
|
- Same artifact family generated locally:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- optional `model_equation.json` (for `linear_regression`)
|
||||||
|
- Cleanup behavior is now tolerant:
|
||||||
|
- cleanup runs in guarded `finally`
|
||||||
|
- training success is not reverted if cleanup later fails
|
||||||
|
|
||||||
|
#### 3.3.5 Quick matrix (before vs now)
|
||||||
|
|
||||||
|
- **Postgres**
|
||||||
|
- before: status + run_name + errors
|
||||||
|
- now: same persisted shape; workflow return contains extra IDs
|
||||||
|
- **MLflow**
|
||||||
|
- before: legacy model objects + params/metrics + report/data artifacts
|
||||||
|
- now: wrapper-based model persistence + `mse_val`/`mae_val`/`r2_val` as params + report/data artifacts + run_id exposed
|
||||||
|
- **MinIO**
|
||||||
|
- before: reads 1 CSV input object
|
||||||
|
- now: reads 1 or 2 CSV input objects (train + optional validation), still no output upload
|
||||||
|
- **Local temp**
|
||||||
|
- before: generated artifacts, then cleanup
|
||||||
|
- now: generated artifacts, then best-effort cleanup (non-blocking for success result)
|
||||||
|
|
||||||
|
### 3.4 Cleanup behavior impact on output semantics
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Cleanup was called directly after training result; failures propagated straightforwardly.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Cleanup is in a guarded `finally`.
|
||||||
|
- If training succeeded but cleanup fails, workflow warns and does not rollback success semantics.
|
||||||
|
- Effective output semantics: successful training result can be returned even if temp cleanup fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Detailed field mapping (old -> new)
|
||||||
|
|
||||||
|
## Kept (or equivalent role)
|
||||||
|
|
||||||
|
- `experiment_run_id` -> kept (broader accepted types: int or numeric string)
|
||||||
|
- `variable_columns` -> kept
|
||||||
|
- `target_variable` -> kept
|
||||||
|
- `bucket_name` -> kept
|
||||||
|
- `file_name` -> kept
|
||||||
|
- `line_separator` -> kept
|
||||||
|
- `decimal_separator` -> kept
|
||||||
|
- `date_column` -> kept optional
|
||||||
|
- `date_format` -> kept optional
|
||||||
|
- `train_size` -> kept
|
||||||
|
- `shuffle` -> kept
|
||||||
|
- `model_name` -> kept (now less coupled to legacy model enum)
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- `model_type` (primary selector for plugin wrapper/index lookup)
|
||||||
|
- `data_model_kwargs`
|
||||||
|
- `model_kwargs`
|
||||||
|
- `opt_params`
|
||||||
|
- `val_file_name` (optional second dataset input)
|
||||||
|
- `model_id` (optional metadata)
|
||||||
|
- `model_metadata` (loaded/required for schema validation)
|
||||||
|
- `random_state` (explicit split reproducibility control)
|
||||||
|
|
||||||
|
## Removed from new required contract
|
||||||
|
|
||||||
|
- `lag_train`, `lag_val`
|
||||||
|
- `rem_static_win`, `static_threshold`
|
||||||
|
- `low_lim`, `upp_lim`
|
||||||
|
- `window`
|
||||||
|
- `use_scaler`, `include_ar`
|
||||||
|
- `degree`, `interaction_only`
|
||||||
|
- `nan_treatment`
|
||||||
|
- `start_date`, `end_date`
|
||||||
|
- `scaler_name`
|
||||||
|
- `removed_intervals`
|
||||||
|
- `support_filters`
|
||||||
|
- `experiment_name` (no longer required as top-level client input)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Internal architecture update notes
|
||||||
|
|
||||||
|
### 5.1 Repository layer redesign
|
||||||
|
|
||||||
|
Removed:
|
||||||
|
- `model_manager/utils/repository/model_repository.py`
|
||||||
|
- `model_manager/utils/repository/training_repository.py`
|
||||||
|
- `model_manager/utils/repository/storage_repository.py`
|
||||||
|
|
||||||
|
Added:
|
||||||
|
- `model_manager/utils/repository/data_manager_repository.py`
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
- Data preprocessing/report/metrics responsibilities were consolidated into `DataManagerRepository`.
|
||||||
|
- Training/model persistence shifted to wrapper + plugin store + MLflow repository integrations.
|
||||||
|
|
||||||
|
### 5.2 Model engine abstraction migration
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Strong coupling to local classes in `model_manager.sientia.models` and custom preprocessing/model objects in `TrainModelResult`.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Training uses `SientiaModel` wrapper dynamically obtained by `plugin_store.get_model(model_type=...)`.
|
||||||
|
- Contract is wrapper-driven (`train`, `transform`, `predict`, `store_model`).
|
||||||
|
- The codebase removed `model_manager/sientia/models.py`, `model_serving.py`, and `utils.py`, indicating full migration to externalized model runtime abstraction.
|
||||||
|
|
||||||
|
### 5.3 Worker/runtime architecture changes
|
||||||
|
|
||||||
|
- New `prepare_worker.py` centralizes worker setup and autoscaling parameters.
|
||||||
|
- Queue names are now runtime-derived:
|
||||||
|
- `train_model-<runtime>-queue`
|
||||||
|
- `cleanup_files-<runtime>-queue`
|
||||||
|
- `worker.py` now installs runtime via plugin store (`plugin_store.install_runtime(runtime_name=...)`) before starting workers.
|
||||||
|
- This introduces environment/runtime-aware deployment and model packaging behavior.
|
||||||
|
|
||||||
|
### 5.4 Synchronous activity and tracking adjustments
|
||||||
|
|
||||||
|
- `experiment_tracking` migrated from async postgres helper to sync postgres client path (`postgres_sync`).
|
||||||
|
- Several activities switched to sync method signatures.
|
||||||
|
- Error handling in workflow and DB status update paths is more defensive (secondary failures while persisting error status are logged and do not mask primary failure cause).
|
||||||
|
|
||||||
|
### 5.5 `TrainModelResult` shape update
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Stored classic split artifacts (`x_train`, `x_test`, `y_train`, `y_test`) + concrete preprocessing/model objects (`process_data`, `regr`, `scaler_dict`).
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Stores `train_data`, `val_data` and prediction DataFrames, plus tracking identifiers (`experiment_name`, `run_id`).
|
||||||
|
- Result object is less tied to internal estimator classes and more aligned with serializable workflow/model-store integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Net IO compatibility assessment
|
||||||
|
|
||||||
|
## Input compatibility
|
||||||
|
|
||||||
|
Not backward compatible with old payloads without adaptation.
|
||||||
|
|
||||||
|
Key reasons:
|
||||||
|
- Legacy required fields removed/ignored by new path.
|
||||||
|
- New required fields introduced (`model_type`, `*_kwargs` dicts, runtime metadata flow dependency).
|
||||||
|
- Validation pipeline now expects model metadata semantics.
|
||||||
|
|
||||||
|
## Output compatibility
|
||||||
|
|
||||||
|
Behavior changed:
|
||||||
|
- Workflow now returns a result object (previously `None`).
|
||||||
|
- Training summary includes `experiment_name` and `run_id` in addition to `run_name` and `run_dir`.
|
||||||
|
- DB update still centered on `run_name`; callers relying only on DB may not see all new output info unless workflow return is consumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Practical migration guidance (client side)
|
||||||
|
|
||||||
|
To call `train_model` in this branch:
|
||||||
|
|
||||||
|
1. Send snake_case payload aligned to new `TrainModelParams`.
|
||||||
|
2. Always provide:
|
||||||
|
- `model_name` slugified model name (ex.: `test_model_name or test-model-name`)
|
||||||
|
- `model_type`
|
||||||
|
- `data_model_kwargs` (dict)
|
||||||
|
- `model_kwargs` (dict)
|
||||||
|
- `opt_params` (dict)
|
||||||
|
3. Keep `experiment_run_id` numeric (int or numeric string).
|
||||||
|
4. Use runtime queue naming consistent with worker runtime:
|
||||||
|
- `train_model-<runtime>-queue`
|
||||||
|
5. If you need explicit validation split file, send `val_file_name`; otherwise split uses `train_size`/`shuffle`/`random_state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Source references used for this document
|
||||||
|
|
||||||
|
Primary diffs:
|
||||||
|
- `model_manager/workflows/train_model.py`
|
||||||
|
- `model_manager/utils/models/train_model_params.py`
|
||||||
|
- `model_manager/utils/models/train_model_result.py`
|
||||||
|
- `model_manager/activities/training.py`
|
||||||
|
- `model_manager/activities/activities.py`
|
||||||
|
- `model_manager/activities/experiment_tracking.py`
|
||||||
|
- `model_manager/utils/repository/data_manager_repository.py`
|
||||||
|
- `model_manager/utils/repository/model_repository.py` (removed)
|
||||||
|
- `model_manager/utils/repository/training_repository.py` (removed)
|
||||||
|
- `model_manager/utils/repository/storage_repository.py` (removed)
|
||||||
|
- `model_manager/worker/worker.py`
|
||||||
|
- `model_manager/worker/prepare_worker.py`
|
||||||
|
- `README.md`
|
||||||
|
- `input-sample.md`
|
||||||
|
- `scripts/run_training_test.py`
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ with workflow.unsafe.imports_passed_through():
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import mlflow
|
import mlflow
|
||||||
import pandas as pd
|
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
from sientia_do.notifications.models import NotificationLevel
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
from sientia_do.observability.logger import Logger
|
from sientia_do.observability.logger import Logger
|
||||||
@@ -340,10 +339,30 @@ class Training(SientiaMonitoring):
|
|||||||
self.info(f'Storing model for {train_params.model_type}', metadata)
|
self.info(f'Storing model for {train_params.model_type}', metadata)
|
||||||
wrapper._input_example = None
|
wrapper._input_example = None
|
||||||
wrapper.store_model(name=train_params.model_name)
|
wrapper.store_model(name=train_params.model_name)
|
||||||
|
self._log_regression_metrics_as_params(train_result)
|
||||||
self.info(f'Logging artifacts for {train_params.model_type}', metadata)
|
self.info(f'Logging artifacts for {train_params.model_type}', metadata)
|
||||||
mlflow.log_artifact(train_result.report_path)
|
mlflow.log_artifact(train_result.report_path)
|
||||||
mlflow.log_artifact(train_result.train_data_path)
|
mlflow.log_artifact(train_result.train_data_path)
|
||||||
mlflow.log_artifact(train_result.test_data_path)
|
mlflow.log_artifact(train_result.test_data_path)
|
||||||
|
if train_result.equation_path is not None:
|
||||||
|
mlflow.log_artifact(train_result.equation_path)
|
||||||
|
|
||||||
|
def _log_regression_metrics_as_params(self, train_result: TrainModelResult) -> None:
|
||||||
|
"""
|
||||||
|
Persist computed regression metrics as MLflow params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
train_result: Training output containing computed regression metrics.
|
||||||
|
"""
|
||||||
|
metric_params = {
|
||||||
|
'mse_val': train_result.mse_val,
|
||||||
|
'mae_val': train_result.mae_val,
|
||||||
|
'r2_val': train_result.r2_val,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in metric_params.items():
|
||||||
|
if value is not None:
|
||||||
|
mlflow.log_param(key, value)
|
||||||
|
|
||||||
@activity.defn(name='cleanup_resources')
|
@activity.defn(name='cleanup_resources')
|
||||||
def cleanup_resources(self, input_data: dict[str, Any]) -> None:
|
def cleanup_resources(self, input_data: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from evidently.metrics import (
|
|||||||
)
|
)
|
||||||
from evidently.metrics.base_metric import generate_column_metrics
|
from evidently.metrics.base_metric import generate_column_metrics
|
||||||
from evidently.options import ColorOptions
|
from evidently.options import ColorOptions
|
||||||
from evidently.report import Report
|
|
||||||
from evidently.pipeline.column_mapping import ColumnMapping
|
from evidently.pipeline.column_mapping import ColumnMapping
|
||||||
|
from evidently.report import Report
|
||||||
|
|
||||||
COLOR_DISCRETE_SEQUENCE = (
|
COLOR_DISCRETE_SEQUENCE = (
|
||||||
'#ed0400',
|
'#ed0400',
|
||||||
@@ -36,6 +36,7 @@ def load_html_from_file(file_path):
|
|||||||
with open(file_path, encoding='utf-8') as file:
|
with open(file_path, encoding='utf-8') as file:
|
||||||
return file.read()
|
return file.read()
|
||||||
|
|
||||||
|
|
||||||
def inject_content(main_html, section_id, content):
|
def inject_content(main_html, section_id, content):
|
||||||
soup = BeautifulSoup(main_html, 'html.parser')
|
soup = BeautifulSoup(main_html, 'html.parser')
|
||||||
section = soup.find(id=section_id)
|
section = soup.find(id=section_id)
|
||||||
@@ -68,7 +69,12 @@ class Reports:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, reference_data: Any, current_data: Any, target_name: str, base_path: str | None = None, template_path: str | None = None
|
self,
|
||||||
|
reference_data: Any,
|
||||||
|
current_data: Any,
|
||||||
|
target_name: str,
|
||||||
|
base_path: str | None = None,
|
||||||
|
template_path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initializes an instance of the AigReport class.
|
Initializes an instance of the AigReport class.
|
||||||
@@ -247,10 +253,10 @@ class Reports:
|
|||||||
if output_dir and not os.path.exists(output_dir):
|
if output_dir and not os.path.exists(output_dir):
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
print(f"Output directory: {output_dir}")
|
print(f'Output directory: {output_dir}')
|
||||||
print(f"Report path: {report_path}")
|
print(f'Report path: {report_path}')
|
||||||
print(f"Base path: {self.base_path}")
|
print(f'Base path: {self.base_path}')
|
||||||
print(f"Template path: {self.template_path}")
|
print(f'Template path: {self.template_path}')
|
||||||
|
|
||||||
# Load main HTML template
|
# Load main HTML template
|
||||||
main_html_path = os.path.join(self.template_path, 'header.html')
|
main_html_path = os.path.join(self.template_path, 'header.html')
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def validate_frontend_date_format(fmt: str | None) -> None:
|
|||||||
allowed = ', '.join(sorted(ALLOWED_FRONTEND_DATE_FORMATS))
|
allowed = ', '.join(sorted(ALLOWED_FRONTEND_DATE_FORMATS))
|
||||||
raise ValueError(f'Invalid date_format "{fmt}". Allowed formats: {allowed}')
|
raise ValueError(f'Invalid date_format "{fmt}". Allowed formats: {allowed}')
|
||||||
|
|
||||||
|
|
||||||
# Model name constants
|
# Model name constants
|
||||||
MODEL_LINEAR_REGRESSION = 'Linear Regression'
|
MODEL_LINEAR_REGRESSION = 'Linear Regression'
|
||||||
MODEL_POLYNOMIAL_REGRESSION = 'Polynomial Regression'
|
MODEL_POLYNOMIAL_REGRESSION = 'Polynomial Regression'
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from os import makedirs, path
|
|||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mlflow.entities import experiment
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from sientia_do.observability.logger import Logger
|
from sientia_do.observability.logger import Logger
|
||||||
@@ -28,7 +27,7 @@ from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
|||||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
from sientia_model.wrappers.sientia_model import SientiaModel
|
from sientia_model.wrappers.sientia_model import SientiaModel
|
||||||
|
|
||||||
from model_manager.runtime_paths import REPORTS_ROOT, PROJECT_BASE_PATH
|
from model_manager.runtime_paths import PROJECT_BASE_PATH, REPORTS_ROOT
|
||||||
from model_manager.sientia.metrics import mae, mse, r2
|
from model_manager.sientia.metrics import mae, mse, r2
|
||||||
from model_manager.sientia.reports import Reports # type: ignore[import-untyped]
|
from model_manager.sientia.reports import Reports # type: ignore[import-untyped]
|
||||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
@@ -201,10 +200,16 @@ class DataManagerRepository(SientiaMonitoring):
|
|||||||
metadata,
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
experiment_name = f'train_model_{params.model_type}_{params.model_name}_{params.experiment_run_id}'
|
experiment_name = f'{params.model_name}'
|
||||||
run_name = f'{experiment_name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
run_name = f'{experiment_name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
||||||
|
|
||||||
return TrainModelResult(params=params, train_data=train_data, val_data=val_data, run_name=run_name, experiment_name=experiment_name)
|
return TrainModelResult(
|
||||||
|
params=params,
|
||||||
|
train_data=train_data,
|
||||||
|
val_data=val_data,
|
||||||
|
run_name=run_name,
|
||||||
|
experiment_name=experiment_name,
|
||||||
|
)
|
||||||
|
|
||||||
def _as_series(self, pred: pd.DataFrame | pd.Series) -> pd.Series:
|
def _as_series(self, pred: pd.DataFrame | pd.Series) -> pd.Series:
|
||||||
if isinstance(pred, pd.Series):
|
if isinstance(pred, pd.Series):
|
||||||
@@ -422,7 +427,9 @@ class DataManagerRepository(SientiaMonitoring):
|
|||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _set_timezone_on_index(self, data: pd.DataFrame, metadata: dict[str, Any] | None = None) -> pd.DataFrame:
|
def _set_timezone_on_index(
|
||||||
|
self, data: pd.DataFrame, metadata: dict[str, Any] | None = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
Check if the index has a timezone and if not, set it to UTC timezone.
|
Check if the index has a timezone and if not, set it to UTC timezone.
|
||||||
|
|
||||||
@@ -443,6 +450,7 @@ class DataManagerRepository(SientiaMonitoring):
|
|||||||
raise ValueError('Index is not a DatetimeIndex')
|
raise ValueError('Index is not a DatetimeIndex')
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _get_reports_directory(self) -> str:
|
def _get_reports_directory(self) -> str:
|
||||||
"""
|
"""
|
||||||
Get the absolute path to the reports directory.
|
Get the absolute path to the reports directory.
|
||||||
@@ -513,23 +521,14 @@ class DataManagerRepository(SientiaMonitoring):
|
|||||||
if data.y_train_pred is None or data.y_pred is None:
|
if data.y_train_pred is None or data.y_pred is None:
|
||||||
raise ValueError('y_train_pred or y_pred is not set, cannot generate report')
|
raise ValueError('y_train_pred or y_pred is not set, cannot generate report')
|
||||||
|
|
||||||
|
y_train_pred = data.y_train_pred.rename(columns={data.params.target_variable: 'prediction'})
|
||||||
y_train_pred = data.y_train_pred.rename(
|
y_val_pred = data.y_pred.rename(columns={data.params.target_variable: 'prediction'})
|
||||||
columns={data.params.target_variable: 'prediction'}
|
|
||||||
)
|
|
||||||
y_val_pred = data.y_pred.rename(
|
|
||||||
columns={data.params.target_variable: 'prediction'}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Join the predictions to the data
|
# Join the predictions to the data
|
||||||
reference_data = y_train_pred[['prediction']].join(
|
reference_data = y_train_pred[['prediction']].join(data.train_data, how='inner')
|
||||||
data.train_data, how='inner'
|
|
||||||
)
|
|
||||||
reference_data_float = reference_data.astype(np.float64)
|
reference_data_float = reference_data.astype(np.float64)
|
||||||
|
|
||||||
current_data = y_val_pred[['prediction']].join(
|
current_data = y_val_pred[['prediction']].join(data.val_data, how='inner')
|
||||||
data.val_data, how='inner'
|
|
||||||
)
|
|
||||||
current_data_float = current_data.astype(np.float64)
|
current_data_float = current_data.astype(np.float64)
|
||||||
|
|
||||||
# Initialize report generator
|
# Initialize report generator
|
||||||
|
|||||||
@@ -132,7 +132,12 @@ def test_train_model_success_serializes_result(mock_mlflow, training):
|
|||||||
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
side_effect=lambda x, _w: setattr(x, 'mse_val', 0.1) or x
|
side_effect=lambda x, _w, **_kw: (
|
||||||
|
setattr(x, 'mse_val', 0.1),
|
||||||
|
setattr(x, 'mae_val', 0.2),
|
||||||
|
setattr(x, 'r2_val', 0.9),
|
||||||
|
x,
|
||||||
|
)[-1]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _fill_report(x, **_kw):
|
def _fill_report(x, **_kw):
|
||||||
@@ -145,12 +150,7 @@ def test_train_model_success_serializes_result(mock_mlflow, training):
|
|||||||
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
|
||||||
wrapper = MagicMock()
|
wrapper = MagicMock()
|
||||||
wrapper.transform = MagicMock(
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
side_effect=[
|
|
||||||
(train_df, None),
|
|
||||||
(val_df, None),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
pred_train = pd.DataFrame({'p': [1.0, 2.0]})
|
pred_train = pd.DataFrame({'p': [1.0, 2.0]})
|
||||||
pred_val = pd.DataFrame({'p': [1.0]})
|
pred_val = pd.DataFrame({'p': [1.0]})
|
||||||
wrapper.predict = MagicMock(side_effect=[(pred_train, None), (pred_val, None)])
|
wrapper.predict = MagicMock(side_effect=[(pred_train, None), (pred_val, None)])
|
||||||
@@ -167,12 +167,64 @@ def test_train_model_success_serializes_result(mock_mlflow, training):
|
|||||||
training.mlflow_repository.start_run = _run_ctx
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
|
||||||
out = training.train_model({'metadata': {'pod': 'p'}, 'train_params': tp.to_dict()})
|
out = training.train_model({'metadata': {'pod': 'p'}, 'train_params': tp.to_dict()})
|
||||||
assert out['run_name'] == 'run-n'
|
assert out['run_name'] is None
|
||||||
assert out['run_id'] == 'run-i'
|
assert out['run_id'] == 'run-i'
|
||||||
assert out['run_dir'] == '/tmp/run'
|
assert out['run_dir'] == '/tmp/run'
|
||||||
|
mock_mlflow.log_param.assert_any_call('mse_val', 0.1)
|
||||||
|
mock_mlflow.log_param.assert_any_call('mae_val', 0.2)
|
||||||
|
mock_mlflow.log_param.assert_any_call('r2_val', 0.9)
|
||||||
mock_mlflow.log_artifact.assert_called()
|
mock_mlflow.log_artifact.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_without_logger_does_not_set_wrapper_logger(_mock_mlflow, training):
|
||||||
|
"""Covers branch where activity logger is None."""
|
||||||
|
training.logger = None
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report(x, **_kw):
|
||||||
|
x.report_path = '/tmp/report.html'
|
||||||
|
x.train_data_path = '/tmp/train.csv'
|
||||||
|
x.test_data_path = '/tmp/test.csv'
|
||||||
|
x.equation_path = '/tmp/eq.json'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_name = 'n'
|
||||||
|
info.run_id = 'i'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
@patch('model_manager.activities.training.mlflow')
|
@patch('model_manager.activities.training.mlflow')
|
||||||
def test_train_model_train_params_as_dict(mock_mlflow, training):
|
def test_train_model_train_params_as_dict(mock_mlflow, training):
|
||||||
"""train_params may arrive as dict and is coerced via TrainModelParams.from_dict."""
|
"""train_params may arrive as dict and is coerced via TrainModelParams.from_dict."""
|
||||||
@@ -188,7 +240,7 @@ def test_train_model_train_params_as_dict(mock_mlflow, training):
|
|||||||
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
side_effect=lambda x, _w: x
|
side_effect=lambda x, _w, **_kw: x
|
||||||
)
|
)
|
||||||
|
|
||||||
def _fill_report2(x, **_kw):
|
def _fill_report2(x, **_kw):
|
||||||
@@ -243,7 +295,7 @@ def test_train_model_downloads_validation_file_when_set(mock_mlflow, training):
|
|||||||
training.minio_repository.download_file = MagicMock(side_effect=_dl)
|
training.minio_repository.download_file = MagicMock(side_effect=_dl)
|
||||||
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
side_effect=lambda x, _w: x
|
side_effect=lambda x, _w, **_kw: x
|
||||||
)
|
)
|
||||||
|
|
||||||
def _fill(x, **_kw):
|
def _fill(x, **_kw):
|
||||||
@@ -291,7 +343,7 @@ def test_train_model_value_error_when_paths_missing_after_report(training):
|
|||||||
training.minio_repository.download_file = MagicMock(return_value=b'x')
|
training.minio_repository.download_file = MagicMock(return_value=b'x')
|
||||||
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
side_effect=lambda x, _w: x
|
side_effect=lambda x, _w, **_kw: x
|
||||||
)
|
)
|
||||||
training.data_manager_repository.generate_report = MagicMock(return_value=tmr)
|
training.data_manager_repository.generate_report = MagicMock(return_value=tmr)
|
||||||
wrapper = MagicMock()
|
wrapper = MagicMock()
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ def _make_dummy(name: str) -> type:
|
|||||||
|
|
||||||
def _stub_evidently() -> None:
|
def _stub_evidently() -> None:
|
||||||
"""Minimal Evidently API surface required to import `model_manager.sientia.reports`."""
|
"""Minimal Evidently API surface required to import `model_manager.sientia.reports`."""
|
||||||
|
ev = ModuleType('evidently')
|
||||||
|
sys.modules['evidently'] = ev
|
||||||
|
|
||||||
mp = ModuleType('evidently.metric_preset')
|
mp = ModuleType('evidently.metric_preset')
|
||||||
mp.DataDriftPreset = _make_dummy('DataDriftPreset')
|
mp.DataDriftPreset = _make_dummy('DataDriftPreset')
|
||||||
sys.modules['evidently.metric_preset'] = mp
|
sys.modules['evidently.metric_preset'] = mp
|
||||||
@@ -51,6 +54,13 @@ def _stub_evidently() -> None:
|
|||||||
opt.ColorOptions = _make_dummy('ColorOptions')
|
opt.ColorOptions = _make_dummy('ColorOptions')
|
||||||
sys.modules['evidently.options'] = opt
|
sys.modules['evidently.options'] = opt
|
||||||
|
|
||||||
|
pipeline = ModuleType('evidently.pipeline')
|
||||||
|
sys.modules['evidently.pipeline'] = pipeline
|
||||||
|
|
||||||
|
colmap = ModuleType('evidently.pipeline.column_mapping')
|
||||||
|
colmap.ColumnMapping = _make_dummy('ColumnMapping')
|
||||||
|
sys.modules['evidently.pipeline.column_mapping'] = colmap
|
||||||
|
|
||||||
rep = ModuleType('evidently.report')
|
rep = ModuleType('evidently.report')
|
||||||
rep.Report = _make_dummy('Report')
|
rep.Report = _make_dummy('Report')
|
||||||
sys.modules['evidently.report'] = rep
|
sys.modules['evidently.report'] = rep
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,9 +31,8 @@ def test_load_html_from_file_success(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_load_html_from_file_missing_file():
|
def test_load_html_from_file_missing_file():
|
||||||
result = reports.load_html_from_file('non-existent.html')
|
with pytest.raises(FileNotFoundError):
|
||||||
|
reports.load_html_from_file('non-existent.html')
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_html_from_file_os_error(monkeypatch):
|
def test_load_html_from_file_os_error(monkeypatch):
|
||||||
@@ -42,9 +41,8 @@ def test_load_html_from_file_os_error(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr('builtins.open', fake_open)
|
monkeypatch.setattr('builtins.open', fake_open)
|
||||||
|
|
||||||
result = reports.load_html_from_file('path.html')
|
with pytest.raises(OSError, match='boom'):
|
||||||
|
reports.load_html_from_file('path.html')
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_inject_content_replaces_section():
|
def test_inject_content_replaces_section():
|
||||||
@@ -71,7 +69,7 @@ def test_inject_content_missing_section():
|
|||||||
|
|
||||||
|
|
||||||
def test_reports_init_sets_defaults(stub_color_options):
|
def test_reports_init_sets_defaults(stub_color_options):
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
|
||||||
assert report.metrics == []
|
assert report.metrics == []
|
||||||
assert isinstance(report.options, list) and len(report.options) == 1
|
assert isinstance(report.options, list) and len(report.options) == 1
|
||||||
@@ -89,7 +87,7 @@ def test_add_data_quality_section_without_run(monkeypatch, stub_color_options):
|
|||||||
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: 'conflict')
|
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: 'conflict')
|
||||||
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: 'correlations')
|
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: 'correlations')
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
report.add_data_quality_section(columns=['col'], run=False)
|
report.add_data_quality_section(columns=['col'], run=False)
|
||||||
|
|
||||||
assert report.metrics[-4:] == [
|
assert report.metrics[-4:] == [
|
||||||
@@ -120,7 +118,9 @@ def test_add_data_quality_section_with_run(monkeypatch, tmp_path, stub_color_opt
|
|||||||
ReportMock = MagicMock(return_value=report_instance)
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path))
|
report = reports.Reports(
|
||||||
|
reference_data='ref', current_data='cur', target_name='target', base_path=str(tmp_path)
|
||||||
|
)
|
||||||
report.add_data_quality_section(columns=['c1'], run=True)
|
report.add_data_quality_section(columns=['c1'], run=True)
|
||||||
|
|
||||||
assert report.metrics[-4:] == [summary, column_metrics, conflict, correlations]
|
assert report.metrics[-4:] == [summary, column_metrics, conflict, correlations]
|
||||||
@@ -153,7 +153,7 @@ def test_add_data_quality_section_run_without_base_path(monkeypatch, stub_color_
|
|||||||
ReportMock = MagicMock(return_value=report_instance)
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
report.add_data_quality_section(run=True)
|
report.add_data_quality_section(run=True)
|
||||||
|
|
||||||
assert report.sections['data_quality'] == {'result': 'quality'}
|
assert report.sections['data_quality'] == {'result': 'quality'}
|
||||||
@@ -170,7 +170,9 @@ def test_add_data_drift_section_paths(monkeypatch, tmp_path, stub_color_options)
|
|||||||
ReportMock = MagicMock(return_value=report_instance)
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path))
|
report = reports.Reports(
|
||||||
|
reference_data='ref', current_data='cur', target_name='target', base_path=str(tmp_path)
|
||||||
|
)
|
||||||
report.add_data_drift_section(columns=['c1'], run=False)
|
report.add_data_drift_section(columns=['c1'], run=False)
|
||||||
assert report.metrics[-1] == drift_instances[0]
|
assert report.metrics[-1] == drift_instances[0]
|
||||||
assert 'data_drift' not in report.sections
|
assert 'data_drift' not in report.sections
|
||||||
@@ -192,7 +194,7 @@ def test_add_data_drift_section_run_without_base_path(monkeypatch, stub_color_op
|
|||||||
ReportMock = MagicMock(return_value=report_instance)
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
report.add_data_drift_section(run=True)
|
report.add_data_drift_section(run=True)
|
||||||
|
|
||||||
assert report.sections['data_drift'] == {'result': 'drift'}
|
assert report.sections['data_drift'] == {'result': 'drift'}
|
||||||
@@ -216,7 +218,9 @@ def test_add_regression_section(monkeypatch, tmp_path, stub_color_options):
|
|||||||
ReportMock = MagicMock(return_value=report_instance)
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path))
|
report = reports.Reports(
|
||||||
|
reference_data='ref', current_data='cur', target_name='target', base_path=str(tmp_path)
|
||||||
|
)
|
||||||
|
|
||||||
report.add_regression_section(run=False)
|
report.add_regression_section(run=False)
|
||||||
assert report.metrics[-7:] == regression_metrics
|
assert report.metrics[-7:] == regression_metrics
|
||||||
@@ -225,7 +229,11 @@ def test_add_regression_section(monkeypatch, tmp_path, stub_color_options):
|
|||||||
report.add_regression_section(run=True)
|
report.add_regression_section(run=True)
|
||||||
assert report.sections['regression'] == {'result': 'regression'}
|
assert report.sections['regression'] == {'result': 'regression'}
|
||||||
ReportMock.assert_called_with(metrics=regression_metrics, options=report.options)
|
ReportMock.assert_called_with(metrics=regression_metrics, options=report.options)
|
||||||
report_instance.run.assert_called_with(reference_data='ref', current_data='cur')
|
report_instance.run.assert_called_with(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
column_mapping=report_instance.run.call_args.kwargs['column_mapping'],
|
||||||
|
)
|
||||||
report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'regression.html'))
|
report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'regression.html'))
|
||||||
|
|
||||||
|
|
||||||
@@ -246,7 +254,7 @@ def test_add_regression_section_run_without_base_path(monkeypatch, stub_color_op
|
|||||||
ReportMock = MagicMock(return_value=report_instance)
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
monkeypatch.setattr(reports, 'Report', ReportMock)
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
report.add_regression_section(run=True)
|
report.add_regression_section(run=True)
|
||||||
|
|
||||||
assert report.sections['regression'] == {'result': 'reg'}
|
assert report.sections['regression'] == {'result': 'reg'}
|
||||||
@@ -262,7 +270,7 @@ def test_set_color_options_appends(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(reports, 'ColorOptions', color_options_mock)
|
monkeypatch.setattr(reports, 'ColorOptions', color_options_mock)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
report.set_color_options(primary_color='#111', secondary_color='#222')
|
report.set_color_options(primary_color='#111', secondary_color='#222')
|
||||||
|
|
||||||
assert len(report.options) == 2
|
assert len(report.options) == 2
|
||||||
@@ -272,12 +280,24 @@ def test_set_color_options_appends(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_save_all_sections_html_requires_base_path(stub_color_options):
|
def test_save_all_sections_html_requires_base_path(stub_color_options):
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur')
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
report.save_all_sections_html('output/report.html')
|
report.save_all_sections_html('output/report.html')
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_sections_html_requires_template_path(stub_color_options, tmp_path):
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='template_path is required'):
|
||||||
|
report.save_all_sections_html('output/report.html')
|
||||||
|
|
||||||
|
|
||||||
def test_save_all_sections_html_writes_output(tmp_path, stub_color_options):
|
def test_save_all_sections_html_writes_output(tmp_path, stub_color_options):
|
||||||
base_dir = tmp_path / 'templates'
|
base_dir = tmp_path / 'templates'
|
||||||
base_dir.mkdir()
|
base_dir.mkdir()
|
||||||
@@ -289,7 +309,13 @@ def test_save_all_sections_html_writes_output(tmp_path, stub_color_options):
|
|||||||
(base_dir / 'data_quality.html').write_text('<p>Quality</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')
|
(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))
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(base_dir),
|
||||||
|
template_path=str(base_dir),
|
||||||
|
)
|
||||||
output_path = tmp_path / 'reports' / 'combined.html'
|
output_path = tmp_path / 'reports' / 'combined.html'
|
||||||
|
|
||||||
report.save_all_sections_html(str(output_path))
|
report.save_all_sections_html(str(output_path))
|
||||||
@@ -313,7 +339,13 @@ def test_save_all_sections_html_creates_directory(monkeypatch, tmp_path, stub_co
|
|||||||
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||||
|
|
||||||
make_dirs_called = []
|
make_dirs_called = []
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir))
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(base_dir),
|
||||||
|
template_path=str(base_dir),
|
||||||
|
)
|
||||||
output_path = tmp_path / 'nested' / 'report.html'
|
output_path = tmp_path / 'nested' / 'report.html'
|
||||||
output_dir = str(output_path.parent)
|
output_dir = str(output_path.parent)
|
||||||
|
|
||||||
@@ -356,7 +388,13 @@ def test_save_all_sections_html_no_directory_needed(monkeypatch, tmp_path, stub_
|
|||||||
monkeypatch.setattr(os, 'makedirs', fake_makedirs)
|
monkeypatch.setattr(os, 'makedirs', fake_makedirs)
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir))
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(base_dir),
|
||||||
|
template_path=str(base_dir),
|
||||||
|
)
|
||||||
report.save_all_sections_html('report.html')
|
report.save_all_sections_html('report.html')
|
||||||
|
|
||||||
assert mk_calls == []
|
assert mk_calls == []
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
from model_manager.utils.models.train_model_params import (
|
||||||
|
TrainModelParams,
|
||||||
|
validate_frontend_date_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -262,3 +265,11 @@ def test_validate_model_param_all_schema_branches(valid_train_params_dict):
|
|||||||
p.model_kwargs = {}
|
p.model_kwargs = {}
|
||||||
p.opt_params = {}
|
p.opt_params = {}
|
||||||
p.validate_business_rules()
|
p.validate_business_rules()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_frontend_date_format_whitespace_returns():
|
||||||
|
validate_frontend_date_format(' ')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_frontend_date_format_valid_returns():
|
||||||
|
validate_frontend_date_format('dd/MM/yyyy HH:mm:ss')
|
||||||
|
|||||||
@@ -100,10 +100,23 @@ def test_prepare_training_data_empty_after_load():
|
|||||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||||
# empty csv with headers only
|
# empty csv with headers only
|
||||||
csv_bytes = b'v1,t\n'
|
csv_bytes = b'v1,t\n'
|
||||||
with pytest.raises(ValueError, match='Training data view is empty'):
|
with pytest.raises(ValueError, match='Index is not a DatetimeIndex'):
|
||||||
repo.prepare_training_data(csv_bytes, None, p, {})
|
repo.prepare_training_data(csv_bytes, None, p, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_training_data_empty_after_transformation(monkeypatch):
|
||||||
|
repo = dmr.DataManagerRepository(MagicMock())
|
||||||
|
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
repo,
|
||||||
|
'_configure_datetime_index',
|
||||||
|
lambda *_args, **_kwargs: pd.DataFrame(columns=['v1', 't']),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(repo, '_set_timezone_on_index', lambda data, *_args, **_kwargs: data)
|
||||||
|
with pytest.raises(ValueError, match='Training data view is empty after transformation'):
|
||||||
|
repo.prepare_training_data(b'v1,t\n', None, p, {})
|
||||||
|
|
||||||
|
|
||||||
def _minimal_dict_for_prepare():
|
def _minimal_dict_for_prepare():
|
||||||
return {
|
return {
|
||||||
'variable_columns': ['v1'],
|
'variable_columns': ['v1'],
|
||||||
@@ -364,6 +377,8 @@ def test_generate_report_success(tmp_path):
|
|||||||
params=p,
|
params=p,
|
||||||
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||||
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||||
|
y_train_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||||
|
y_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||||
run_name='testrun',
|
run_name='testrun',
|
||||||
)
|
)
|
||||||
tmr.equation = {'target_variable': 't'}
|
tmr.equation = {'target_variable': 't'}
|
||||||
@@ -388,6 +403,8 @@ def test_generate_report_skips_equation_file_when_not_linear(tmp_path):
|
|||||||
params=p,
|
params=p,
|
||||||
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||||
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||||
|
y_train_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||||
|
y_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||||
run_name='testrun',
|
run_name='testrun',
|
||||||
equation={'k': 'v'},
|
equation={'k': 'v'},
|
||||||
)
|
)
|
||||||
@@ -412,6 +429,21 @@ def test_generate_report_run_name_missing():
|
|||||||
repo.generate_report(tmr, {})
|
repo.generate_report(tmr, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_report_requires_predictions():
|
||||||
|
repo = dmr.DataManagerRepository(MagicMock())
|
||||||
|
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||||
|
tmr = TrainModelResult(
|
||||||
|
params=p,
|
||||||
|
train_data=pd.DataFrame({'t': [1.0]}),
|
||||||
|
val_data=pd.DataFrame({'t': [1.0]}),
|
||||||
|
run_name='testrun',
|
||||||
|
y_train_pred=None,
|
||||||
|
y_pred=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match='y_train_pred or y_pred is not set'):
|
||||||
|
repo.generate_report(tmr, {})
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_run_directory_empty():
|
def test_cleanup_run_directory_empty():
|
||||||
repo = dmr.DataManagerRepository(MagicMock())
|
repo = dmr.DataManagerRepository(MagicMock())
|
||||||
repo.cleanup_run_directory('', {})
|
repo.cleanup_run_directory('', {})
|
||||||
|
|||||||
Reference in New Issue
Block a user