From 06571011f2f827f3f5605aeaab34730d4bef3c63 Mon Sep 17 00:00:00 2001 From: Bruno Domingues Date: Fri, 19 Dec 2025 15:44:18 -0300 Subject: [PATCH] SIENTIAPDE-1430: Introduce static_threshold parameter for static window removal. This parameter allows customizing the threshold (1-1000) used when rem_static_win is enabled, defaulting to 1 if null. Updates include parameter definition, business rule validation, repository logic for passing the threshold, documentation in README.md and PIPELINE_PARAMS_CHANGELOG.md, and new unit and integration tests. --- PIPELINE_PARAMS_CHANGELOG.md | 3 + README.md | 4 + ...ar-regression-static-threshold-custom.json | 29 ++++++ .../utils/models/train_model_params.py | 10 ++ .../utils/repository/model_repository.py | 5 +- .../utils/repository/training_repository.py | 4 +- tests/utils/models/test_train_model_params.py | 94 +++++++++++++++++++ tests/utils/models/test_train_model_result.py | 1 + .../utils/repository/test_model_repository.py | 2 + .../repository/test_training_repository.py | 25 ++++- 10 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 docs/test-scenarios/11-linear-regression-static-threshold-custom.json diff --git a/PIPELINE_PARAMS_CHANGELOG.md b/PIPELINE_PARAMS_CHANGELOG.md index 4998212..35fc794 100644 --- a/PIPELINE_PARAMS_CHANGELOG.md +++ b/PIPELINE_PARAMS_CHANGELOG.md @@ -28,6 +28,7 @@ Este documento descreve as alterações nos parâmetros de entrada do pipeline T | `start_date` | `str \| null` | Data inicial para filtrar dados (formato: `"YYYY-MM-DD HH:MM:SS"`) | `null` | | `end_date` | `str \| null` | Data final para filtrar dados (formato: `"YYYY-MM-DD HH:MM:SS"`) | `null` | | `support_filters` | `dict \| null` | Filtros customizados por variável | `{}` | +| `static_threshold` | `int \| null` | Threshold para remoção de janelas estáticas (1-1000). Só usado quando `rem_static_win` é `true`. | `1` | --- @@ -79,6 +80,7 @@ Este documento descreve as alterações nos parâmetros de entrada do pipeline T }, "rem_static_win": true, + "static_threshold": null, "low_lim": {"pressao": 0, "umidade": 0, "velocidade": 0}, "upp_lim": {"pressao": 100, "umidade": 100, "velocidade": 50}, "window": 0, @@ -212,6 +214,7 @@ O sistema valida automaticamente: 11. **`degree` vs `model_name`**: Se `model_name` = "Polynomial Regression", `degree` deve ser >= 2; se "Linear Regression", `degree` deve ser = 1 12. **`removed_intervals`**: Cada elemento deve ser lista/tupla com pelo menos 2 elementos (start, end) 13. **`target_variable`**: Não pode estar vazio +14. **`static_threshold`**: Se `rem_static_win` = `true` e `static_threshold` tiver valor, deve estar entre 1 e 1000 (inclusive). Se `null`, assume valor `1`. --- diff --git a/README.md b/README.md index 1a9c46e..e27e636 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ The **TrainModel** workflow orchestrates the complete ML model training pipeline "lag_train": {"feature1": 0, "feature2": 2}, "lag_val": {"feature1": 0, "feature2": 1}, "rem_static_win": false, + "static_threshold": null, "low_lim": {"feature1": 0.0, "feature2": 0.0}, "upp_lim": {"feature1": 100.0, "feature2": 100.0}, "window": 10, @@ -359,6 +360,7 @@ The workflow validates comprehensive business rules beyond type checking: 11. **model_name**: Must be 'Linear Regression' or 'Polynomial Regression' 12. **Polynomial Regression requires Scaler**: Models with degree > 1 must have a scaler to prevent numerical overflow 13. **Linear Regression requires degree 1**: Linear models must have degree = 1 +14. **static_threshold**: When `rem_static_win` is true and `static_threshold` has a value, it must be between 1 and 1000 (inclusive). If null, defaults to 1. ### Cleanup Files Workflow (`cleanup_files.py`) @@ -977,6 +979,7 @@ Test scenarios are defined as JSON files in `docs/test-scenarios/`. Each scenari | `08-linear-regression-with-limits` | Linear regression with variable limits | `lowLim`/`uppLim` configuration | | `09-polynomial-degree2-with-scaler-and-lags` | Complete polynomial scenario | Scaler + lags + degree 2 | | `10-linear-regression-with-ar` | Linear regression with autoregressive variable | `includeAr: true` | +| `11-linear-regression-static-threshold-custom` | Linear regression with custom static threshold | `staticThreshold: 100` | #### Scenario File Structure @@ -991,6 +994,7 @@ Test scenarios are defined as JSON files in `docs/test-scenarios/`. Each scenari "lagTrain": {"feature1": 0, "feature2": 0}, "lagVal": {"feature1": 0, "feature2": 0}, "remStaticWin": false, + "staticThreshold": null, "lowLim": {}, "uppLim": {}, "window": 0, diff --git a/docs/test-scenarios/11-linear-regression-static-threshold-custom.json b/docs/test-scenarios/11-linear-regression-static-threshold-custom.json new file mode 100644 index 0000000..8afa37e --- /dev/null +++ b/docs/test-scenarios/11-linear-regression-static-threshold-custom.json @@ -0,0 +1,29 @@ +{ + "_description": "Regressão linear com remoção de janelas estáticas e static_threshold customizado", + "experimentName": "test-linear-static-threshold", + "username": "bruno.domingues@aignosi.com.br", + "modelName": "Linear Regression", + "targetVariable": "03CV020/CORRENTE_N_M1_PV(Value)", + "variableColumns": ["303-WIT-200(Value)"], + "lagTrain": {"303-WIT-200(Value)": 0}, + "lagVal": {"303-WIT-200(Value)": 0}, + "remStaticWin": true, + "staticThreshold": 100, + "lowLim": {}, + "uppLim": {}, + "window": 10, + "useScaler": false, + "includeAr": false, + "trainSize": 80, + "shuffle": true, + "lineSeparator": ",", + "decimalSeparator": ".", + "removedIntervals": [], + "degree": 1, + "interactionOnly": false, + "nanTreatment": "drop", + "startDate": null, + "endDate": null, + "scalerName": "None", + "supportFilters": {} +} diff --git a/model_manager/utils/models/train_model_params.py b/model_manager/utils/models/train_model_params.py index e88a3f0..8ab83f9 100644 --- a/model_manager/utils/models/train_model_params.py +++ b/model_manager/utils/models/train_model_params.py @@ -47,6 +47,7 @@ class TrainModelParams: end_date (str | None): End date for filtering data. scaler_name (str): Name of the scaler to use ('Standard Scaler' or 'None'). support_filters (dict): Custom support filters per variable. + static_threshold (int | None): Threshold for static window removal (1-1000). Only used when rem_static_win is True. """ variable_columns: list[str] @@ -76,6 +77,7 @@ class TrainModelParams: end_date: str | None scaler_name: str support_filters: dict + static_threshold: int | None @classmethod def from_dict(cls, data: dict[str, Any]) -> 'TrainModelParams': @@ -138,6 +140,7 @@ class TrainModelParams: scaler_name=cls._check_none(data.get('scaler_name'), str, 'scaler_name'), support_filters=cls._check_type(data.get('support_filters'), dict, 'support_filters') or {}, + static_threshold=cls._check_type(data.get('static_threshold'), int, 'static_threshold'), ) @staticmethod @@ -228,6 +231,13 @@ class TrainModelParams: if self.window < 0: raise ValueError(f'window must be non-negative, got {self.window}') + # Validate static_threshold only when rem_static_win is True and value is provided + if self.rem_static_win and self.static_threshold is not None: + if not 1 <= self.static_threshold <= 1000: + raise ValueError( + f'static_threshold must be between 1 and 1000, got {self.static_threshold}' + ) + def _validate_model_params(self) -> None: """Validate model-related parameters.""" if self.degree < 1: diff --git a/model_manager/utils/repository/model_repository.py b/model_manager/utils/repository/model_repository.py index 47d542e..123c54b 100644 --- a/model_manager/utils/repository/model_repository.py +++ b/model_manager/utils/repository/model_repository.py @@ -206,7 +206,10 @@ class ModelRepository: self.model_serving.log_param('lag_train', data.params.lag_train) self.model_serving.log_param('lag_transform', data.params.lag_val) self.model_serving.log_param( - 'static_threshold', 1 if data.params.rem_static_win else None + 'static_threshold', + (data.params.static_threshold if data.params.static_threshold is not None else 1) + if data.params.rem_static_win + else None, ) self.model_serving.log_param('lower_limits', data.params.low_lim) self.model_serving.log_param('upper_limits', data.params.upp_lim) diff --git a/model_manager/utils/repository/training_repository.py b/model_manager/utils/repository/training_repository.py index cd3019f..2c6e396 100644 --- a/model_manager/utils/repository/training_repository.py +++ b/model_manager/utils/repository/training_repository.py @@ -269,7 +269,9 @@ class TrainingRepository: start_date=params.start_date, end_date=params.end_date, removed_intervals=removed_intervals, - static_threshold=1 if params.rem_static_win else None, + static_threshold=(params.static_threshold if params.static_threshold is not None else 1) + if params.rem_static_win + else None, low_lim=params.low_lim, upp_lim=params.upp_lim, scaler_name=params.scaler_name, diff --git a/tests/utils/models/test_train_model_params.py b/tests/utils/models/test_train_model_params.py index 3b65c60..5c5443a 100644 --- a/tests/utils/models/test_train_model_params.py +++ b/tests/utils/models/test_train_model_params.py @@ -34,6 +34,7 @@ def valid_train_params_dict(): 'end_date': None, 'scaler_name': 'Standard Scaler', 'support_filters': {}, + 'static_threshold': None, } @@ -564,3 +565,96 @@ def test_validate_business_rules_polynomial_regression_valid(valid_train_params_ params = TrainModelParams.from_dict(valid_train_params_dict) params.validate_business_rules() # Should not raise + + +def test_validate_business_rules_static_threshold_valid(valid_train_params_dict): + """Test validate_business_rules accepts valid static_threshold when rem_static_win is True.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = True + valid_train_params_dict['static_threshold'] = 500 + params = TrainModelParams.from_dict(valid_train_params_dict) + + params.validate_business_rules() # Should not raise + + +def test_validate_business_rules_static_threshold_min_valid(valid_train_params_dict): + """Test validate_business_rules accepts static_threshold = 1.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = True + valid_train_params_dict['static_threshold'] = 1 + params = TrainModelParams.from_dict(valid_train_params_dict) + + params.validate_business_rules() # Should not raise + + +def test_validate_business_rules_static_threshold_max_valid(valid_train_params_dict): + """Test validate_business_rules accepts static_threshold = 1000.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = True + valid_train_params_dict['static_threshold'] = 1000 + params = TrainModelParams.from_dict(valid_train_params_dict) + + params.validate_business_rules() # Should not raise + + +def test_validate_business_rules_static_threshold_below_min(valid_train_params_dict): + """Test validate_business_rules raises error when static_threshold < 1.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = True + valid_train_params_dict['static_threshold'] = 0 + params = TrainModelParams.from_dict(valid_train_params_dict) + + with pytest.raises(ValueError, match='static_threshold must be between 1 and 1000, got 0'): + params.validate_business_rules() + + +def test_validate_business_rules_static_threshold_above_max(valid_train_params_dict): + """Test validate_business_rules raises error when static_threshold > 1000.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = True + valid_train_params_dict['static_threshold'] = 1001 + params = TrainModelParams.from_dict(valid_train_params_dict) + + with pytest.raises(ValueError, match='static_threshold must be between 1 and 1000, got 1001'): + params.validate_business_rules() + + +def test_validate_business_rules_static_threshold_none_when_rem_static_win_true( + valid_train_params_dict, +): + """Test validate_business_rules accepts None static_threshold when rem_static_win is True.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = True + valid_train_params_dict['static_threshold'] = None + params = TrainModelParams.from_dict(valid_train_params_dict) + + params.validate_business_rules() # Should not raise - None is allowed + + +def test_validate_business_rules_static_threshold_ignored_when_rem_static_win_false( + valid_train_params_dict, +): + """Test validate_business_rules ignores static_threshold when rem_static_win is False.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['rem_static_win'] = False + valid_train_params_dict['static_threshold'] = 5000 # Invalid value, but should be ignored + params = TrainModelParams.from_dict(valid_train_params_dict) + + params.validate_business_rules() # Should not raise - validation skipped + + +def test_from_dict_static_threshold_type_error(valid_train_params_dict): + """Test from_dict raises TypeError when static_threshold has wrong type.""" + from model_manager.utils.models.train_model_params import TrainModelParams + + valid_train_params_dict['static_threshold'] = 'not_an_int' + + with pytest.raises(TypeError, match='static_threshold must be of type int, but got str'): + TrainModelParams.from_dict(valid_train_params_dict) diff --git a/tests/utils/models/test_train_model_result.py b/tests/utils/models/test_train_model_result.py index bba7b06..daee5b3 100644 --- a/tests/utils/models/test_train_model_result.py +++ b/tests/utils/models/test_train_model_result.py @@ -40,6 +40,7 @@ def sample_params(): end_date=None, scaler_name='Standard Scaler', support_filters={}, + static_threshold=None, ) diff --git a/tests/utils/repository/test_model_repository.py b/tests/utils/repository/test_model_repository.py index 8d7d84a..00ab54a 100644 --- a/tests/utils/repository/test_model_repository.py +++ b/tests/utils/repository/test_model_repository.py @@ -55,6 +55,8 @@ def mock_train_result(): result.params.include_ar = False result.params.train_size = 80 result.params.removed_intervals = [] + result.params.rem_static_win = True + result.params.static_threshold = None result.run_name = 'test_run' result.run_dir = '/tmp/test_run' # noqa: S108 result.report_path = '/tmp/test_run/report.html' # noqa: S108 diff --git a/tests/utils/repository/test_training_repository.py b/tests/utils/repository/test_training_repository.py index 21c170a..443f61b 100644 --- a/tests/utils/repository/test_training_repository.py +++ b/tests/utils/repository/test_training_repository.py @@ -56,6 +56,7 @@ def sample_params(): end_date=None, scaler_name='None', support_filters={}, + static_threshold=None, ) @@ -137,6 +138,7 @@ class TestExtractModelEquation: end_date=None, scaler_name='None', support_filters={}, + static_threshold=None, ) # Mock model with single coefficient @@ -221,12 +223,33 @@ class TestInitDataPreprocessor: assert preprocessor.ar_var is None def test_init_preprocessor_with_static_removal(self, training_repo, sample_params): - """Test preprocessor with static window removal enabled.""" + """Test preprocessor with static window removal enabled and no static_threshold.""" sample_params.rem_static_win = True + sample_params.static_threshold = None preprocessor = training_repo._init_data_preprocessor(sample_params) assert preprocessor.static_threshold == 1 + def test_init_preprocessor_with_static_removal_custom_threshold( + self, training_repo, sample_params + ): + """Test preprocessor with static window removal and custom static_threshold.""" + sample_params.rem_static_win = True + sample_params.static_threshold = 500 + preprocessor = training_repo._init_data_preprocessor(sample_params) + + assert preprocessor.static_threshold == 500 + + def test_init_preprocessor_without_static_removal_ignores_threshold( + self, training_repo, sample_params + ): + """Test preprocessor without static removal ignores static_threshold.""" + sample_params.rem_static_win = False + sample_params.static_threshold = 500 + preprocessor = training_repo._init_data_preprocessor(sample_params) + + assert preprocessor.static_threshold is None + def test_init_preprocessor_lag_configuration(self, training_repo, sample_params): """Test preprocessor lag configuration.""" sample_params.lag_train = {'var1': 5, 'var2': 5, 'var3': 5}