diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e1b539d..2c6d39b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,7 +23,8 @@ jobs: - name: Install dependencies run: | - pip install sphinx sphinx-rtd-theme sphinx-autodoc-typehints sphinx-copybutton + pip install -r requirements-docs.txt + pip install -r requirements.txt - name: Create VERSION file run: echo "${GITHUB_REF##*/}" > "${PKG_NAME}/VERSION" diff --git a/README.md b/README.md index be2e1f0..92de174 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,15 @@ For an example using one of the CARE2Compare datasets, run: For more information, have a look at the notebook [Quick Fault Detection](./notebooks/Example%20-%20Quick%20Fault%20Detection.ipynb) -## Fault detection in 4 lines of code +## Fault detection quickstart ```python from energy_fault_detector import FaultDetector, Config +from energy_fault_detector.config import generate_quickstart_config +# 1) Generate and save a base config (YAML) +generate_quickstart_config(output_path="base_config.yaml") +# 2) Train and predict using the generated config fault_detector = FaultDetector(config=Config('base_config.yaml')) model_data = fault_detector.train(sensor_data=sensor_data, normal_index=normal_index) results = fault_detector.predict(sensor_data=test_sensor_data) @@ -60,7 +64,7 @@ results = fault_detector.predict(sensor_data=test_sensor_data) The pandas `DataFrame` `sensor_data` contains the operational data in wide format with the timestamp as index, the pandas `Series` `normal_index` indicates which timestamps are considered 'normal' operation and can be used to create -a normal behaviour model. The [`base_config.yaml`](energy_fault_detector/base_config.yaml) file contains all model +a normal behaviour model. The [`base_config.yaml`](energy_fault_detector/base_config.yaml) file contains the model settings, an example is found [here](energy_fault_detector/base_config.yaml). @@ -84,10 +88,8 @@ All contributions, bug reports, bug fixes, documentation improvements, enhanceme 2. Unification, standardisation and generic improvements 1. Additional options for all autoencoders (e.g. drop out, regularization) 2. Data preparation (e.g. extend imputation strategies). - 3. Download method for the Care2Compare class. - 3. Unify default value settings. - 4. No or low configuration - 5. Upgrade to Keras 3.0 + 3. No or low configuration need (e.g. use defaults where possible). + 4. Upgrade to Keras 3.0 3. Root cause analysis expansion 1. integrate SHAP and possibly other XAI-methods. @@ -98,12 +100,17 @@ This project is licensed under the [MIT License](./LICENSE). ## References If you use this work, please cite us: +**Fault detection in district heating substations**: +- Enabling Predictive Maintenance in District Heating Substations: A Labelled Dataset and Fault Detection Evaluation Framework based on Service Data. +PrePrint on ArXiv. https://doi.org/10.48550/arXiv.2511.14791 +- Dataset: PreDist Dataset - Operational data of district heating substations labelled with faults and maintenance information. Zenodo, Nov 2025, https://doi.org/10.5281/zenodo.17522254. + **ARCANA Algorithm**: Autoencoder-based anomaly root cause analysis for wind turbines. Energy and AI. 2021;4:100065. https://doi.org/10.1016/j.egyai.2021.100065 **CARE to Compare dataset and CARE-Score**: - Paper: CARE to Compare: A Real-World Benchmark Dataset for Early Fault Detection in Wind Turbine Data. Data. 2024; 9(12):138. https://doi.org/10.3390/data9120138 -- Dataset: Wind Turbine SCADA Data For Early Fault Detection. Zenodo, Mar. 2025, https://doi.org/10.5281/ZENODO.14958989. +- Dataset: Wind Turbine SCADA Data For Early Fault Detection. Zenodo, Oct. 2024, https://doi.org/10.5281/ZENODO.14958989. **Transfer learning methods**: Transfer learning applications for autoencoder-based anomaly detection in wind turbines. Energy and AI. 2024;17:100373. https://doi.org/10.1016/j.egyai.2024.100373 diff --git a/docs/advanced_config.yaml b/docs/advanced_config.yaml new file mode 100644 index 0000000..ac25ad0 --- /dev/null +++ b/docs/advanced_config.yaml @@ -0,0 +1,111 @@ +train: + # clip training data to remove outliers (only applied for training) + data_clipping: # (optional) if not specified, not applied. + lower_percentile: 0.01 + upper_percentile: 0.99 + # Choose one of: + # features_to_exclude: + # - do_not_clip_this_feature + # features_to_clip: + # - clip_only_this_feature + + data_preprocessor: + steps: + # Replace consecutive duplicate 0-values with NaN + - name: duplicate_to_nan + params: + value_to_replace: 0 + n_max_duplicates: 6 + features_to_exclude: + - do_not_replace_value_with_nan_for_this_feature + # Normalize counters to differences (configure your counter columns) + # If needed, you can create multiple counter_diff_transformer steps with different settings for different counters + - name: counter_diff_transformer + step_name: counter_diff_energy + params: + counters: + - energy_total_kwh + compute_rate: false + reset_strategy: zero + fill_first: nan + # Column selection: drop columns where > 20% is missing and exclude specific features + - name: column_selector + params: + max_nan_frac_per_col: 0.20 + features_to_exclude: + - feature1 + - feature2 + # Alternatively, keep only selected features: + # features_to_select: + # - temp_outdoor + # - flow + # - power + # Filter low unique value features or high-zero-fraction columns + - name: low_unique_value_filter + params: + min_unique_value_count: 2 + max_col_zero_frac: 0.99 + # Transform angles to sin/cos + - name: angle_transformer + params: + angles: + - angle1 + - angle2 + # Imputer (explicit; will be auto-inserted if omitted) + - name: simple_imputer + params: + strategy: mean + # Scaler (choose one; StandardScaler is auto-added by default if omitted) + - name: standard_scaler + params: + with_mean: true + with_std: true + + data_splitter: + # How to split data in train and validation sets for the autoencoder + type: sklearn + validation_split: 0.2 + shuffle: true # false by default (last part of the data is taken as validation data in this case) + # or block splitting, 4 weeks training, 1 week validation + # type: DataSplitter + # train_block_size: 4032 + # val_block_size: 1008 + + autoencoder: + name: MultilayerAutoencoder + params: + batch_size: 128 + # Use a ExponentialDecay schedule for the learning rate: + learning_rate: 0.001 # starting point + decay_rate: 0.99 + decay_steps: 100000 + # Set early stopping with max 1000 epochs, minimal improvement of 1e-4 and patience of 5 epochs + early_stopping: True + min_delta: 0.0001 + patience: 5 + epochs: 1000 + # architecture settings + layers: [200, 100, 50] + code_size: 20 + act: prelu # activation to use for hidden layers + last_act: linear # output layer activation + + anomaly_score: + name: rmse + params: + scale: false + + threshold_selector: + name: fbeta + params: + beta: 0.5 + +root_cause_analysis: + alpha: 0.5 + init_x_bias: recon + num_iter: 1000 + verbose: true + +predict: + criticality: + max_criticality: 144 diff --git a/docs/basic_config.yaml b/docs/basic_config.yaml new file mode 100644 index 0000000..d927187 --- /dev/null +++ b/docs/basic_config.yaml @@ -0,0 +1,45 @@ +train: + # clip training data to remove outliers (only applied for training) + data_clipping: # (optional) if not specified, not applied. + # Use features_to_exclude or features_to_clip: [feature] to skip or to apply to specific features + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + steps: + # This drops features where > 20% is missing + - name: column_selector + params: + max_nan_frac_per_col: 0.2 + # This drops constants by default (controlled by `min_unique_value_count`) + - name: low_unique_value_filter + # SimpleImputer and StandardScaler are always added + + data_splitter: + # How to split data in train and validation sets for the autoencoder + type: sklearn + validation_split: 0.2 + shuffle: true + + autoencoder: + name: default + params: + layers: # Symmetric autoencoder: inputs - 200 - 100 - 50 - 20 - 50 - 100 - 200 - outputs + - 200 + - 100 + - 50 + code_size: 20 # Size of the bottleneck layer + + anomaly_score: + name: rmse + + threshold_selector: + fit_on_val: true + name: quantile + params: + quantile: 0.95 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/docs/conf.py b/docs/conf.py index c4de4e8..7aba3a6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -127,6 +127,11 @@ napoleon_use_param = True napoleon_use_rtype = True +napoleon_type_aliases = { + "Config": "energy_fault_detector.Config", + "FaultDetector": "energy_fault_detector.FaultDetector", +} + # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..530b5bd --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,136 @@ +.. _configuration_guide: + +Configuration +================================ +This page explains how to configure training, prediction, and optional root cause analysis (ARCANA). + +.. contents:: Table of Contents + :depth: 3 + :local: + +Quick start: minimal configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +A minimal configuration that clips outliers, imputes missing values, and scales features: + +.. include:: basic_config.yaml + :literal: + +This setup: + +- Applies DataClipper if specified. +- Builds a DataPreprocessor with: + + - ColumnSelector that drops columns with more than 20% NaNs (configurable). + - LowUniqueValueFilter that removes constant features by default (configurable). + - SimpleImputer (mean) and a scaler (StandardScaler by default). If you do not add an imputer/scaler explicitly, + the pipeline ensures mean-imputation and StandardScaler are added. + +- Trains a default autoencoder (with provided architecture, otherwise default values), with an RMSE anomaly score and a + quantile threshold selector. +- Runs ARCANA with provided parameters when calling :py:obj:`FaultDetector.predict(..., root_cause_analysis=True) `. + If not provided, default ARCANA parameters are used (see :py:obj:`ARCANA docs `). + +If you leave out the data_preprocessor configuration (i.e., ``data_preprocessor: {}``), a default preprocessing pipeline +is generated, which drops constant features, features where >5% of the data is missing, imputes remaining missing values +with the mean value and scales the data to zero mean and unit standard deviation. + +Detailed configuration +^^^^^^^^^^^^^^^^^^^^^^ +Below is a more thorough configuration. It shows how to specify preprocessing steps and more model parameters. + +.. include:: advanced_config.yaml + :literal: + +DataPreprocessor specification +"""""""""""""""""""""""""""""" +A steps-based preprocessing pipeline can be configured under ``train.data_preprocessor.steps``. Each step is a dict +with the following keys: + +- ``name`` (str): the registered step name (see table below). +- ``enabled`` (bool, optional): default ``True``; set to ``False`` to skip a step. +- ``params`` (dict, optional): constructor arguments for the step. +- ``step_name`` (str, optional): custom key for the sklearn pipeline; useful if a step is repeated. + +Allowed step names and aliases: + ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| Step name | Purpose | Aliases | ++=========================+===============================================+================================================+ +| column_selector | Drop columns with too many NaNs | \- | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| low_unique_value_filter | Drop columns with low variance/many zeros | \- | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| angle_transformer | Convert angles to sin/cos pairs | angle_transform | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| counter_diff_transformer| Convert counters to differences/rates | counter_diff, counter_diff_transform | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| simple_imputer | Impute missing values | imputer | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| standard_scaler | Standardize features (z-score) | standardize, standardscaler, standard | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| minmax_scaler | Scale to [0, 1] | minmax | ++-------------------------+-----------------------------------------------+------------------------------------------------+ +| duplicate_to_nan | Replace consecutive duplicate values with NaN | duplicate_value_to_nan, duplicate_values_to_nan| ++-------------------------+-----------------------------------------------+------------------------------------------------+ + +For detailed documentation of the data preprocessor pipeline, refer to the +:py:obj:`DataPreprocessor ` docs. + +Other training configuration sections +""""""""""""""""""""""""""""""""""""" + +- Data clipping: + :py:obj:`DataClipper ` supports + ``features_to_exclude`` and ``features_to_clip`` for fine-grained control. + + +- Data splitter (``train.data_splitter``): + + - ``type``: one of ``BlockDataSplitter`` (aliases: ``blocks``, ``DataSplitter``), or ``sklearn`` (alias ``train_test_split``). + - For sklearn: ``validation_split`` (float in (0, 1)) and ``shuffle`` (bool). + - For :py:obj:`BlockDataSplitter `: ``train_block_size`` and ``val_block_size``. + - Early stopping guard: if ``train.autoencoder.params.early_stopping`` is true, you must either set a + valid ``validation_split`` in (0, 1), or use :py:obj:`BlockDataSplitter ` + with a positive ``val_block_size``. + + +- Autoencoder (``train.autoencoder``): + + - ``name``: class name in the registry. + - ``params``: architecture and training args (e.g., ``layers``, ``epochs``, ``learning_rate``, ``early_stopping``). + Refer to the autoencoder class docs (:py:obj:`autoencoders `) for specific params and their defaults. + +- Anomaly score (``train.anomaly_score``): + + - ``name``: score name (e.g., ``rmse``, ``mahalanobis``). + - ``params``: score-specific parameters. Refer to the :py:obj:`anomaly_scores ` docs. + +- Threshold selector (``train.threshold_selector``): + + - ``name``: e.g., ``quantile``, ``fbeta``, etc. + - ``fit_on_val``: fit the threshold on validation only. + - ``params``: selector-specific parameters (e.g., ``quantile`` for the quantile selector). + See the :py:obj:`threshold_selectors ` docs for more info on the settings. + +Prediction options +^^^^^^^^^^^^^^^^^^ +Under ``predict``, you can set: + +- ``criticality.max_criticality``: cap the calculated criticality (anomaly counter) to this value. + + +Root cause analysis (ARCANA) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +If ``root_cause_analysis`` is provided, ARCANA will attempt to attribute anomalies to specific features using the +provided settings. If not provided, default settings are used. For detailed documentation refer to +:py:obj:`ARCANA docs `. + + +Old params data preprocessing configuration (for older versions) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Older configurations use params under ``train.data_preprocessor.params``. +These remain supported but are deprecated in favor of steps mode. +When both ``steps`` and legacy params are present, ``steps`` take precedence and legacy params are ignored with a warning. + +.. include:: old_config.yaml + :literal: diff --git a/docs/index.rst b/docs/index.rst index dda8d7b..c13d39e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,10 @@ Energy Fault Detector - Autoencoder-based Fault Detection for the Future Energy System -============================================================ +====================================================================================== **Energy Fault Detector** is an open-source Python package designed for the automated detection of anomalies in operational data from renewable energy systems as well as power grids. It uses autoencoder-based normal behaviour models to identify irregularities in operational data. In addition to the classic anomaly detection, the package -includes the unique “ARCANA” approach for root cause analysis and thus allows interpretable early fault detection. +includes the unique ''ARCANA'' approach for root cause analysis and thus allows interpretable early fault detection. In addition to the pure ML models, the package also contains a range of preprocessing methods, which are particularly useful for analyzing systems in the energy sector. A holistic `EnergyFaultDetector` framework is provided for easy use of all these methods, which can be adapted to the respective use case via a single configuration file. @@ -27,11 +27,10 @@ To install the `energy-fault-detector` package, run: :glob: :maxdepth: 2 - The Energy Fault Detector package usage_examples + configuration logging - changelog - + The EnergyFaultDetector package Module index ================== diff --git a/docs/logging.rst b/docs/logging.rst index 485fcd0..7c943b3 100644 --- a/docs/logging.rst +++ b/docs/logging.rst @@ -1,45 +1,19 @@ Logging Configuration ===================== -The framework uses Python's built-in logging module to provide logging capabilities. By default, the logging -configuration is defined in a YAML file. You can customize this configuration to suit your needs. +The framework uses Python's built-in logging module for logging. +You can customize this configuration to suit your needs. Default Configuration --------------------- -The framework uses a default logging configuration file named ``logging.yaml``. +The framework uses a default logging configuration file ``energy_fault_detector/logging.yaml``. The logger used throughout the code is called ``energy_fault_detector``. -The default logging configuration is as follows. - -.. code-block:: yaml - - version: 1 - disable_existing_loggers: False - formatters: - simple: - format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - - handlers: - console: - class: logging.StreamHandler - level: DEBUG - formatter: simple - stream: ext://sys.stdout - - loggers: - energy_fault_detector: - level: INFO - handlers: [console] - propagate: no - - root: - level: INFO - handlers: [console] - You can silence the logger as follows: .. code-block:: python + import logging from energy_fault_detector.fault_detector import FaultDetector, Config diff --git a/docs/config_example.yaml b/docs/old_config.yaml similarity index 52% rename from docs/config_example.yaml rename to docs/old_config.yaml index 3dd8c39..4e0f454 100644 --- a/docs/config_example.yaml +++ b/docs/old_config.yaml @@ -1,10 +1,5 @@ train: - data_clipping: # (optional) if not specified, not applied. - # clip training data to remove outliers - lower_percentile: 0.01 - upper_percentile: 0.99 - features_to_exclude: - - do_not_clip_this_feature + # ... data_preprocessor: # only imputation and scaling are done by default, other steps can be skipped. @@ -28,39 +23,4 @@ train: duplicate_features_to_exclude: # DuplicateValuesToNan option - list of feature to not transform with DuplicateValuesToNan - do_not_replace_value_with_nan - data_splitter: # (optional) Define block size of train and validation blocks. Optional, if not specified, the defaults are used - # defaults: - type: DataSplitter # or sklearn - train_block_size: 5040 - val_block_size: 1680 # set val_block_size = 0 to use all data for training - - autoencoder: - name: 'MultilayerAutoencoder' - params: - batch_size: 128 - decay_rate: 0.001 # remove decay_rate+decay_steps for a fixed learning rate - decay_steps: 10000 - epochs: 10 - layers: - - 200 # Size of the first and last hidden layer - - 100 # Size of the second and second to last hidden layer - - 50 # Size of the third and third to last hidden layer - code_size: 20 # Size of the bottleneck - learning_rate: 0.001 - loss_name: 'mean_squared_error' - - anomaly_score: - name: 'rmse' - params: - scale: false - - threshold_selector: - name: 'fbeta' - params: - beta: 0.5 - -root_cause_analysis: # (optional) if not specified, no root_cause_analysis (ARCANA) is run - alpha: 0.8 - init_x_bias: recon - num_iter: 200 - + # ... diff --git a/docs/usage_examples.rst b/docs/usage_examples.rst index ed70e09..2dbe77e 100644 --- a/docs/usage_examples.rst +++ b/docs/usage_examples.rst @@ -14,65 +14,63 @@ refer to the example notebooks in the repository's notebooks folder. Energy Fault Detection -^^^^^^^^^^^^^^^ -The main interface for the `energy-fault-detector` package is the :py:obj:`FaultDetector ` class, which -needs a configuration object :py:obj:`Config `. +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The main interface for the `energy-fault-detector` package is the :py:obj:`FaultDetector ` class, which +needs a configuration object :py:obj:`Config `. -To create a new :py:obj:`FaultDetector ` model, +To create a new :py:obj:`FaultDetector ` model, create a configuration, as described below in the :ref:`configuration` section, and run: .. code-block:: python - from energy_fault_detector.fault_detector import FaultDetector - from energy_fault_detector.config import Config + from energy_fault_detector import FaultDetector, Config - config = Config('configs/base_config.yaml') + config = Config('configs/basic_config.yaml') fault_detector = FaultDetector(config=config, model_directory='model_directory') - -To train new models, you need to provide the input data and call the ``fit`` method: +To train new models, you need to provide the input data and call the :py:obj:`FaultDetector.fit ` method: .. code-block:: python # get data from database / csv / API ... sensor_data = ... # a pandas DataFrame with timestamp as index and numerical sensor values as columns normal_index = ... # a pandas Series with timestamp as index and booleans indicating normal behaviour - # NOTE: The normal_index is optional, it is used to select training data for the autoencoder. - # If not provided, we assume all data represents normal behaviour. The other data points are used to set a - # threshold for the fault detection. + # NOTE: The normal_index is optional; it is used to select training data for the autoencoder. + # If not provided, we assume all data represents normal behaviour. + # If you do not have any labels, you cannot use th F-beta-score- and FDR-based thresholds. - # If you do not use the models for time series, the index can also be a standard RangeIndex, as long as the - # sensor_data dataframe and the normal_index series have the same index. + # If you do not use the models for time series, the index can also be a standard RangeIndex, + # as long as the sensor_data DataFrame and the normal_index Series share the same index. model_data = fault_detector.fit(sensor_data=sensor_data, normal_index=normal_index, save_models=True) # to save model manually: # fault_detector.save_models('model_name') # model_name is optional -The trained models are saved locally in the provided ``model_directory``. The ``fit`` method returns a +The trained models are saved locally in the provided ``model_directory``. The :py:obj:`FaultDetector.fit ` method returns a :py:obj:`ModelMetadata ` object with the model metadata such as the model date and the model path. -To predict using the trained model, use the ``predict`` method: +To predict using the trained model, use the :py:obj:`FaultDetector.predict ` method: .. code-block:: python results = fault_detector.predict(sensor_data=test_sensor_data) The result is a :py:obj:`FaultDetectionResult ` object -with with the following information: +with the following information: -* predicted_anomalies: DataFrame with a column 'anomaly' (bool). -* reconstruction: DataFrame with reconstruction of the sensor data with timestamp as index. -* deviations: DataFrame with reconstruction errors. -* anomaly_score: DataFrame with anomaly scores for each timestamp. -* bias_data: DataFrame with ARCANA results with timestamp as index. None if ARCANA was not run. -* arcana_losses: DataFrame containing recorded values for all losses in ARCANA. None if ARCANA was not run. -* tracked_bias: List of DataFrames. None if ARCANA was not run. +* predicted_anomalies: pandas Series with the predicted anomalies (bool). +* reconstruction: pandas DataFrame with reconstruction of the sensor data with timestamp as index. +* deviations: pandas DataFrame with reconstruction errors. +* anomaly_score: pandas Series with anomaly scores for each timestamp. +* bias_data: pandas DataFrame with ARCANA results with timestamp as index. None if ARCANA was not run. +* arcana_losses: pandas DataFrame containing recorded values for all losses in ARCANA. None if ARCANA was not run. +* tracked_bias: List of pandas DataFrames. None if ARCANA was not run. You can also create a :py:obj:`FaultDetector ` object and load -trained models using the ``load_models`` method. In this case, you do not need to provide a ``model_path`` -in the ``predict`` method. +trained models using the :py:obj:`FaultDetector.load_models ` method. In this case, you do not need to provide a ``model_path`` +in the :py:obj:`predict ` method. .. code-block:: python @@ -95,11 +93,17 @@ The training configuration is set with a ``yaml`` file which contains ``train`` train new models and ``root_cause_analysis`` specification if you want to analyse the model predictions with the `ARCANA` algorithm. An example: -.. include:: config_example.yaml +.. include:: basic_config.yaml :literal: +If you leave out the data_preprocessor configuration (i.e., ``data_preprocessor: None``), as default preprocessing +pipeline is generated, which drops constant features, features where >5% of the data is missing, imputes remaining +missing values with the mean value and scales the data to zero mean and unit standard deviation. + +See the :ref:`Configuration guide ` for more details on the configuration file and options. + To update the configuration 'on the fly' (for example for hyperparameter optimization), you provide a new -configuration dictionary via the ``update_config`` method: +configuration dictionary via the :py:obj:`Config.update_config ` method: .. code-block:: python @@ -150,7 +154,7 @@ you can import the data preprocessor, autoencoder, anomaly score and threshold s This allows you to add additional steps or use different data preprocessing pipelines. -An example training pipeline (similar to the :py:obj:`FaultDetector ` class ) +An example training pipeline (similar to the :py:obj:`FaultDetector ` class) would be: .. code-block:: python diff --git a/energy_fault_detector/__init__.py b/energy_fault_detector/__init__.py index 33c5e19..e77c74a 100644 --- a/energy_fault_detector/__init__.py +++ b/energy_fault_detector/__init__.py @@ -46,4 +46,3 @@ version = f.readlines()[0].strip() __version__ = version -__all__ = ['FaultDetector', 'Config', 'registry', 'quick_fault_detector'] diff --git a/energy_fault_detector/base_config.yaml b/energy_fault_detector/base_config.yaml index f708a7e..aebcc2b 100644 --- a/energy_fault_detector/base_config.yaml +++ b/energy_fault_detector/base_config.yaml @@ -4,14 +4,13 @@ train: upper_percentile: 0.999 data_preprocessor: - params: - include_column_selector: true - include_low_unique_value_filter: true - include_duplicate_value_to_nan: false - imputer_strategy: mean - max_nan_frac_per_col: 0.2 - min_unique_value_count: 2 - scale: minmax + steps: + - name: column_selector + params: + max_nan_frac_per_col: 0.2 + - name: low_unique_value_filter + - name: simple_imputer + - name: standard_scaler autoencoder: name: default diff --git a/energy_fault_detector/config/__init__.py b/energy_fault_detector/config/__init__.py index 1184dd5..45c6372 100644 --- a/energy_fault_detector/config/__init__.py +++ b/energy_fault_detector/config/__init__.py @@ -2,3 +2,4 @@ from energy_fault_detector.config.config import Config from energy_fault_detector.config.base_config import InvalidConfigFile +from energy_fault_detector.config.quickstart_config import generate_quickstart_config diff --git a/energy_fault_detector/config/config.py b/energy_fault_detector/config/config.py index 9722615..65e6101 100644 --- a/energy_fault_detector/config/config.py +++ b/energy_fault_detector/config/config.py @@ -34,9 +34,19 @@ 'data_preprocessor': { 'type': 'dict', 'required': True, - 'allow_unknown': True, + 'allow_unknown': False, + 'nullable': True, # if not specfied, create default pipeline 'schema': { - 'params': {'type': 'dict', 'required': False}, + 'params': {'type': 'dict', 'required': False, 'nullable': True,}, + 'steps': { + 'type': 'list', + 'required': False, + 'nullable': True, + 'schema': { + 'type': 'dict', + 'allow_unknown': True + } + }, } }, 'threshold_selector': { @@ -60,7 +70,7 @@ 'type': 'dict', 'required': False, # defaults if not specified 'schema': { - 'type': {'type': 'string', 'required': False, 'default': 'DataSplitter', + 'type': {'type': 'string', 'required': False, 'default': 'BlockDataSplitter', 'allowed': ['DataSplitter', 'BlockDataSplitter', 'blocks', 'sklearn', 'train_test_split']}, 'train_block_size': {'type': 'integer', 'required': False, 'dependencies': {'type': ['DataSplitter', 'BlockDataSplitter', 'blocks']}}, 'val_block_size': {'type': 'integer', 'required': False, 'dependencies': {'type': ['DataSplitter', 'BlockDataSplitter', 'blocks']}}, @@ -88,6 +98,7 @@ 'train': {'type': 'dict', 'schema': TRAIN_SCHEMA, 'required': False, 'allow_unknown': True}, 'predict': {'type': 'dict', 'schema': PREDICT_SCHEMA, 'required': False}, 'root_cause_analysis': {'type': 'dict', 'schema': ROOT_CAUSE_ANALYSIS_SCHEMA, 'required': False}, + 'dtype': {'type': 'string', 'required': False, 'allowed': ['float32', 'float64']} } @@ -179,15 +190,10 @@ def data_clipping_params(self) -> Dict[str, Any]: """Data clipping parameters.""" return self.config_dict.get('train', {}).get('data_clipping', {}) - @property - def angle_columns(self) -> List[str]: - """List of angle columns.""" - return self.config_dict.get('train', {}).get('data_preprocessor', {}).get('params', {}).get('angles', []) - @property def max_criticality(self) -> Optional[int]: """Max criticality value.""" - return self.config_dict.get('prediction', {}).get('criticality', {}).get('max_criticality', 144) + return self.config_dict.get('predict', {}).get('criticality', {}).get('max_criticality', 144) @property def fit_threshold_on_val(self) -> bool: @@ -198,3 +204,8 @@ def fit_threshold_on_val(self) -> bool: def verbose(self) -> int: """Verbosity Level of the Autoencoder.""" return self.config_dict.get('train', {}).get('autoencoder', {}).get('verbose', 1) + + @property + def dtype(self): + """Data type, float32 by default.""" + return self.config_dict.get('dtype', 'float32') diff --git a/energy_fault_detector/config/quickstart_config.py b/energy_fault_detector/config/quickstart_config.py new file mode 100644 index 0000000..208187b --- /dev/null +++ b/energy_fault_detector/config/quickstart_config.py @@ -0,0 +1,246 @@ +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import yaml + + +def _build_preprocessor_steps( + *, + max_nan_frac: float, + min_unique_value_count: int, + max_col_zero_frac: float, + angle_columns: Optional[List[str]], + counter_columns: Optional[List[str]], + imputer_strategy: str, + scaler: str, +) -> List[Dict[str, Any]]: + """ + Build the steps specification for the DataPreprocessor pipeline. + + This helper focuses solely on the steps list for the preprocessing pipeline + and keeps the public function small, readable, and testable. + + Args: + max_nan_frac (float): Maximum fraction of missing values allowed per column. + min_unique_value_count (int): Minimal number of unique values required for a column to remain. + max_col_zero_frac (float): Maximum allowed fraction of zeros in a column (used in the unique-value filter). + angle_columns (Optional[List[str]]): Optional list of column names to be angle-transformed. + counter_columns (Optional[List[str]]): Optional list of counter columns to be transformed to differences. + imputer_strategy (str): SimpleImputer strategy, e.g., "mean", "median", "most_frequent", or "constant". + scaler (str): Scaler type; supports "standard" (and aliases) or "minmax" (and aliases). + + Returns: + List[Dict[str, Any]]: A steps list suitable for DataPreprocessor(steps=[...]). + + Notes: + + - The order is kept minimal here; DataPreprocessor enforces proper ordering internally. + + """ + steps: List[Dict[str, Any]] = [] + + # Optional counter-diff transformation (DataPreprocessor will place it early). + if counter_columns: + steps.append( + { + "name": "counter_diff_transformer", + "params": { + "counters": counter_columns, + "compute_rate": False, + "fill_first": "nan", + }, + } + ) + + # Column selection: drop columns with too many NaNs. + steps.append( + { + "name": "column_selector", + "params": {"max_nan_frac_per_col": max_nan_frac}, + } + ) + + # Filter for columns with very few unique values or many zeros. + steps.append( + { + "name": "low_unique_value_filter", + "params": { + "min_unique_value_count": min_unique_value_count, + "max_col_zero_frac": max_col_zero_frac, + }, + } + ) + + # Optional angle transformer (e.g., degrees => sin/cos). + if angle_columns: + steps.append( + { + "name": "angle_transformer", + "params": {"angles": angle_columns}, + } + ) + + # Explicit imputer; adding it avoids relying on DataPreprocessor defaults. + steps.append( + { + "name": "simple_imputer", + "params": {"strategy": imputer_strategy}, + } + ) + + # Final scaler with aliases supported for convenience. + scaler_key = scaler.lower() + if scaler_key in ("standard", "standardize", "standard_scaler"): + steps.append({"name": "standard_scaler"}) + elif scaler_key in ("minmax", "minmax_scaler", "normalize"): + steps.append({"name": "minmax_scaler"}) + else: + raise ValueError( + f"Unknown scaler '{scaler}'. Use 'standard' (aka 'standardize') or 'minmax'." + ) + + return steps + + +def _dump_yaml_if_requested( + config: Dict[str, Any], + output_path: Optional[Union[str, Path]], +) -> None: + """ + Write the configuration dictionary to a YAML file if a path is provided. + + Args: + config (Dict[str, Any]): The configuration dictionary to serialize. + output_path (Optional[Union[str, Path]]): Destination path. If None, nothing is written. + + Raises: + RuntimeError: If PyYAML is not installed but output_path is not None. + """ + if output_path is None: + return + + if yaml is None: # pragma: no cover - optional dependency + raise RuntimeError( + "PyYAML is not installed; install 'pyyaml' or set output_path=None." + ) + + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + + with path.open("w", encoding="utf-8") as f: + yaml.safe_dump(config, f, sort_keys=False) + + +def generate_quickstart_config( + output_path: Optional[Union[str, Path]] = "base_config.yaml", + *, + # Preprocessor configuration + max_nan_frac: float = 0.05, + min_unique_value_count: int = 2, + max_col_zero_frac: float = 1.0, + angle_columns: Optional[List[str]] = None, + counter_columns: Optional[List[str]] = None, + imputer_strategy: str = "mean", + scaler: str = "standard", + # Early stopping + early_stopping: bool = False, + validation_split: float = 0.2, + # Thresholding + threshold_quantile: float = 0.99, + # Autoencoder defaults + batch_size: int = 128, + code_size: int = 20, + epochs: int = 10, + layers: Optional[List[int]] = None, + learning_rate: float = 1e-3, +) -> Dict[str, Any]: + """ + Generate a minimal, valid configuration for EnergyFaultDetector. + + This function returns a configuration dictionary that uses the steps-based + DataPreprocessor and sensible defaults for training. It can also write the + configuration to YAML if an output path is supplied. + + Example: + from energy_fault_detector import FaultDetector, Config + cfg = generate_quickstart_config(output_path=None) + fault_detector = FaultDetector(config=Config(config_dict=cfg)) + + Args: + output_path (Optional[Union[str, Path]]): YAML output path; set None to return only the dict. + max_nan_frac (float): Max fraction of missing values per column for selection. Default: 0.05 + min_unique_value_count (int): Minimal unique values required to keep a column. Default: 2 + max_col_zero_frac (float): Max fraction of zeros allowed in a column. Default: 1.0 + angle_columns (Optional[List[str]]): Optional columns to transform as angles (sin/cos). Default: None + counter_columns (Optional[List[str]]): Optional counter columns to convert to differences. Default: None + imputer_strategy (str): Strategy for SimpleImputer ("mean", "median", etc.). Default: mean + scaler (str): Scaler selection ("standard" or "minmax"; common aliases allowed). Default: standard + early_stopping (bool): Enable early stopping in the autoencoder training. Default: False + validation_split (float): Fraction for validation in sklearn splitter (0 < val < 1). + threshold_quantile (float): Quantile for the "quantile" threshold selector. Default: 0.99 + batch_size (int): Autoencoder batch size. Default: 128 + code_size (int): Bottleneck code size. Default: 20 + epochs (int): Number of training epochs. Default: 10 + layers (Optional[List[int]]): Autoencoder layer sizes; defaults to [200, 100, 50] if None. + learning_rate (float): Optimizer learning rate. + + Returns: + Dict[str, Any]: Configuration dictionary ready for Config(config_dict=...). + + Raises: + ValueError: If early_stopping is True but validation_split is not in (0, 1). + """ + if not (0 < validation_split < 1.0): + raise ValueError("validation_split must be in (0, 1).") + + # Fallback layers if none provided by user + if layers is None: + layers = [200, 100, 50] + + # Build the preprocessor steps list + steps = _build_preprocessor_steps( + max_nan_frac=max_nan_frac, + min_unique_value_count=min_unique_value_count, + max_col_zero_frac=max_col_zero_frac, + angle_columns=angle_columns, + counter_columns=counter_columns, + imputer_strategy=imputer_strategy, + scaler=scaler, + ) + + # Assemble training configuration + train_config: Dict[str, Any] = { + "data_preprocessor": {"steps": steps}, + "data_splitter": { + "type": "sklearn", + "validation_split": validation_split, + "shuffle": True, + }, + "autoencoder": { + "name": "default", + "params": { + "batch_size": batch_size, + "code_size": code_size, + "early_stopping": early_stopping, + "epochs": epochs, + "layers": layers, + "learning_rate": learning_rate, + }, + "verbose": 1, + }, + "anomaly_score": {"name": "rmse"}, + "threshold_selector": { + "fit_on_val": False, + "name": "quantile", + "params": {"quantile": threshold_quantile}, + }, + # Optional clipping (disabled by default; uncomment to enable): + # "data_clipping": {"lower_percentile": 0.001, "upper_percentile": 0.999}, + } + + config: Dict[str, Any] = {"train": train_config} + + # Optionally write YAML + _dump_yaml_if_requested(config=config, output_path=output_path) + + return config diff --git a/energy_fault_detector/core/__init__.py b/energy_fault_detector/core/__init__.py index 47eca0d..5229541 100644 --- a/energy_fault_detector/core/__init__.py +++ b/energy_fault_detector/core/__init__.py @@ -1,7 +1,8 @@ """This module contains class templates for most of the anomaly detection classes, such as autoencoders, anomaly scores, threshold selectors and data classes.""" -from energy_fault_detector.core.anomaly_score import AnomalyScore -from energy_fault_detector.core.autoencoder import Autoencoder -from energy_fault_detector.core.data_transformer import DataTransformer -from energy_fault_detector.core.threshold_selector import ThresholdSelector +from .anomaly_score import AnomalyScore +from .autoencoder import Autoencoder +from .data_transformer import DataTransformer +from .threshold_selector import ThresholdSelector +from .fault_detection_result import FaultDetectionResult, ModelMetadata \ No newline at end of file diff --git a/energy_fault_detector/_logs.py b/energy_fault_detector/core/_logs.py similarity index 56% rename from energy_fault_detector/_logs.py rename to energy_fault_detector/core/_logs.py index 2cd2a36..2a68147 100644 --- a/energy_fault_detector/_logs.py +++ b/energy_fault_detector/core/_logs.py @@ -1,34 +1,35 @@ """Logging settings""" import os +from pathlib import Path import logging.config as logging_config import yaml -def setup_logging(default_path: str = 'logging.yaml', env_key: str = 'LOG_CFG') -> None: +def setup_logging(default_path: str | Path = 'logging.yaml', env_key: str = 'LOG_CFG') -> None: """Setup logging configuration Args: - default_path (str): default logging configuration file. Default is 'logging.yaml' + default_path (str or Path): default logging configuration file. Default is 'logging.yaml' env_key (str): Environment variable holding logging config file path (overrides default_path). Default is 'LOG_CFG' """ - path = default_path + path = Path(default_path) value = os.getenv(env_key, None) if value: - path = value + path = Path(value) try: with open(path, 'rt', encoding='utf-8') as f: config = yaml.safe_load(f.read()) # check paths exist or create them: for _, handler in config['handlers'].items(): - if handler.get('filename'): - dirname = os.path.dirname(handler['filename']) - if dirname != '' and not os.path.exists(dirname): - os.makedirs(dirname) + filename = handler.get('filename') + if filename: + # Resolve path and create parent directories if they don't exist + Path(filename).parent.mkdir(parents=True, exist_ok=True) logging_config.dictConfig(config) except Exception as e: diff --git a/energy_fault_detector/core/fault_detection_model.py b/energy_fault_detector/core/fault_detection_model.py index 47df523..cd575bb 100644 --- a/energy_fault_detector/core/fault_detection_model.py +++ b/energy_fault_detector/core/fault_detection_model.py @@ -2,9 +2,10 @@ import os from abc import ABC, abstractmethod -from typing import Any, Optional, Union, List, Tuple +from typing import Optional, Union, List, Tuple import logging from datetime import datetime +from pathlib import Path import pandas as pd import numpy as np @@ -16,10 +17,10 @@ from energy_fault_detector.core.model_factory import ModelFactory from energy_fault_detector.core.fault_detection_result import ModelMetadata, FaultDetectionResult from energy_fault_detector.data_preprocessing import DataPreprocessor -from energy_fault_detector._logs import setup_logging +from energy_fault_detector.core._logs import setup_logging from energy_fault_detector.data_splitting.data_splitter import BlockDataSplitter -setup_logging(os.path.join(os.path.dirname(__file__), '..', 'logging.yaml')) +setup_logging(Path(__file__).parent.parent / 'logging.yaml') logger = logging.getLogger('energy_fault_detector') DATA_PREP_DIR = 'data_preprocessor' @@ -28,6 +29,8 @@ SCORE_DIR = 'anomaly_score' DataType = Union[pd.DataFrame, np.ndarray, List] +PathLike = Union[str, Path] +ModelPart = Union[DataPreprocessor, Autoencoder, AnomalyScore, ThresholdSelector] class NoTrainingData(Exception): @@ -50,9 +53,9 @@ class FaultDetectionModel(ABC): save_timestamps: a list of string timestamps, indicating when the model was saved. """ - def __init__(self, config: Optional[Config] = None, model_directory: str = 'models'): + def __init__(self, config: Optional[Config] = None, model_directory: PathLike = 'models'): self.config: Optional[Config] = config - self.model_directory: str = model_directory + self.model_directory: PathLike = model_directory self.anomaly_score: Optional[AnomalyScore] = None self.autoencoder: Optional[Autoencoder] = None @@ -65,6 +68,11 @@ def __init__(self, config: Optional[Config] = None, model_directory: str = 'mode # build models self._model_factory: Optional[ModelFactory] = ModelFactory(config) if config else None + if config is None: + logger.debug('No configuration set. Load models and config from path with the `FaultDetector.load_models`' + ' method.') + else: + self._init_models() def _init_models(self): """Initialize models.""" @@ -79,24 +87,34 @@ def _init_models(self): self.data_preprocessor = self._model_factory.data_preprocessor @abstractmethod - def fit(self, sensor_data: pd.DataFrame, normal_index: pd.Series = None, asset_id: Union[int, str] = None, - **kwargs) -> ModelMetadata: + def fit(self, sensor_data: pd.DataFrame, normal_index: pd.Series = None, save_models: bool = True, + overwrite_models: bool = False, **kwargs) -> ModelMetadata: """Fit models on the given sensor_data and save them locally and return the metadata. Args: - asset_id: asset ID of the asset for which the model should be trained. sensor_data: pandas DataFrame with the sensor data to use. The time stamp should be the index and the sensor values as columns. normal_index: a pandas Series indicating normal behaviour as boolean with the timestamp as index. + save_models (bool, optional): Whether to save models. Defaults to True. + overwrite_models (bool, optional): If True, existing model directories can be overwritten. Defaults to + False. Returns: ModelMetadata object. """ - def train(self, sensor_data: pd.DataFrame, normal_index: pd.Series = None, asset_id: Union[int, str] = None, - **kwargs) -> ModelMetadata: - """Same as the `fit`-method.""" - return self.fit(sensor_data=sensor_data, normal_index=normal_index, asset_id=asset_id, **kwargs) + def train(self, sensor_data: pd.DataFrame, normal_index: pd.Series = None, **kwargs) -> ModelMetadata: + """Same as the `fit`-method. + + Args: + sensor_data: pandas DataFrame with the sensor data to use. + The time stamp should be the index and the sensor values as columns. + normal_index: a pandas Series indicating normal behaviour as boolean with the timestamp as index. + + Returns: + ModelMetadata object. + """ + return self.fit(sensor_data=sensor_data, normal_index=normal_index, **kwargs) @abstractmethod def predict(self, sensor_data: pd.DataFrame, model_path: Optional[str] = None, asset_id: Union[int, str] = None @@ -176,11 +194,11 @@ def save_models(self, model_name: Union[str, int] = None, overwrite: bool = Fals return os.path.abspath(model_dir), current_datetime - def load_models(self, model_path: str) -> None: + def load_models(self, model_path: PathLike) -> None: """Load saved models given the model path. Args: - model_path: Path to the model files. + model_path (str, Path): Path to the model files. """ data_prep_dir = os.path.join(model_path, DATA_PREP_DIR) @@ -206,7 +224,7 @@ def load_models(self, model_path: str) -> None: self._model_factory = ModelFactory(self.config) @staticmethod - def _load_pickled_model(model_type: str, model_directory: str): + def _load_pickled_model(model_type: str, model_directory: str) -> ModelPart: """Load a pickled model of given type, using file name (which is the class name).""" model_class_name = os.listdir(model_directory)[0].split('.')[0] if model_type != 'data_preprocessor': diff --git a/energy_fault_detector/core/fault_detection_result.py b/energy_fault_detector/core/fault_detection_result.py index 52d8d44..6d90928 100644 --- a/energy_fault_detector/core/fault_detection_result.py +++ b/energy_fault_detector/core/fault_detection_result.py @@ -1,11 +1,13 @@ -import os from typing import Optional, List from dataclasses import dataclass +from pathlib import Path import pandas as pd import numpy as np +from ..utils.analysis import calculate_criticality + @dataclass class FaultDetectionResult: @@ -27,36 +29,102 @@ class FaultDetectionResult: """DataFrame with ARCANA results (ARCANA bias). None if ARCANA was not run.""" arcana_losses: Optional[pd.DataFrame] = None - """DataFrame containing recorded values for all losses in ARCANA. None if ARCANA was not run.""" + """DataFrame containing recorded values for all losses in ARCANA. None if ARCANA was not run. + Empty if losses were not tracked.""" tracked_bias: Optional[List[pd.DataFrame]] = None - """List of DataFrames containing the ARCANA bias every 50th iteration. None if ARCANA was not run.""" + """List of DataFrames containing the ARCANA bias every 50th iteration. None if ARCANA was not run. + Empty if bias was not tracked.""" + + def criticality(self, normal_idx: pd.Series | None = None, init_criticality: int = 0, max_criticality: int = 1000 + ) -> pd.Series: + """Criticality based on the predicted anomalies. + + Args: + normal_idx (pd.Series, optional): A pandas Series with boolean values indicating normal operation, indexed + by timestamp. Ignored if None. + init_criticality (int, optional): The initial criticality value. Defaults to 0. + max_criticality (int, optional): The maximum criticality value. Defaults to 1000. + + """ + return calculate_criticality(self.predicted_anomalies, normal_idx, init_criticality, max_criticality) - def save(self, directory: str, **kwargs) -> None: + def save(self, directory: str | Path, **kwargs) -> None: """Saves the results to CSV files in the specified directory. Args: directory (str): The directory where the CSV files will be saved. - kwargs: other keywords args for `pd.DataFrame.to_csv` + kwargs: other keywords args for `pd.DataFrame.to_csv` (i.e. sep=',') """ # Ensure the directory exists - os.makedirs(directory, exist_ok=True) + directory = Path(directory) + directory.mkdir(exist_ok=True, parents=True) # Save each DataFrame as a CSV file - self.predicted_anomalies.to_csv(os.path.join(directory, 'predicted_anomalies.csv'), **kwargs) - self.reconstruction.to_csv(os.path.join(directory, 'reconstruction.csv'), **kwargs) - self.recon_error.to_csv(os.path.join(directory, 'reconstruction_errors.csv'), **kwargs) - self.anomaly_score.to_csv(os.path.join(directory, 'anomaly_scores.csv'), **kwargs) + self.predicted_anomalies.to_csv(directory / 'predicted_anomalies.csv', **kwargs) + self.reconstruction.to_csv(directory / 'reconstruction.csv', **kwargs) + self.recon_error.to_csv(directory / 'reconstruction_errors.csv', **kwargs) + self.anomaly_score.to_csv(directory / 'anomaly_scores.csv', **kwargs) if self.bias_data is not None: - self.bias_data.to_csv(os.path.join(directory, 'bias_data.csv'), **kwargs) + self.bias_data.to_csv(directory / 'bias_data.csv', **kwargs) if self.arcana_losses is not None: - self.arcana_losses.to_csv(os.path.join(directory, 'arcana_losses.csv'), **kwargs) + self.arcana_losses.to_csv(directory / 'arcana_losses.csv', **kwargs) if self.tracked_bias is not None and len(self.tracked_bias) > 0: for idx, bias_df in enumerate(self.tracked_bias): - bias_df.to_csv(os.path.join(directory, f'tracked_bias_{idx}.csv'), **kwargs) + bias_df.to_csv(directory / f'tracked_bias_{idx}.csv', **kwargs) + + @classmethod + def load(cls, directory: str | Path, **kwargs) -> "FaultDetectionResult": + """Loads the results from CSV files in the specified directory. + + Args: + directory (str | Path): The directory where the CSV files are stored. + kwargs: other keywords args for `pd.read_csv` (e.g., sep=',') + + Returns: + FaultDetectionResult: The loaded result object. + """ + directory = Path(directory) + + # Default pandas loading arguments to ensure indices are restored correctly + params = {'index_col': 0, 'parse_dates': True} + params.update(kwargs) + + # Load mandatory fields + predicted_anomalies = pd.read_csv(directory / 'predicted_anomalies.csv', **params).iloc[:, 0] + # Ensure predicted_anomalies is explicitly a Series and boolean + predicted_anomalies = predicted_anomalies.astype(bool) + + reconstruction = pd.read_csv(directory / 'reconstruction.csv', **params) + recon_error = pd.read_csv(directory / 'reconstruction_errors.csv', **params) + anomaly_score = pd.read_csv(directory / 'anomaly_scores.csv', **params).iloc[:, 0] + + # Load optional fields if they exist + bias_data = None + if (directory / 'bias_data.csv').exists(): + bias_data = pd.read_csv(directory / 'bias_data.csv', **params) + + arcana_losses = None + if (directory / 'arcana_losses.csv').exists(): + arcana_losses = pd.read_csv(directory / 'arcana_losses.csv', **params) + + tracked_bias = None + tracked_files = sorted(directory.glob('tracked_bias_*.csv')) + if tracked_files: + tracked_bias = [pd.read_csv(f, **params) for f in tracked_files] + + return cls( + predicted_anomalies=predicted_anomalies, + reconstruction=reconstruction, + recon_error=recon_error, + anomaly_score=anomaly_score, + bias_data=bias_data, + arcana_losses=arcana_losses, + tracked_bias=tracked_bias + ) @dataclass @@ -64,6 +132,6 @@ class ModelMetadata: """Class to encapsulate metadata about the FaultDetector model.""" model_date: str - model_path: str + model_path: str | Path train_recon_error: np.ndarray val_recon_error: Optional[np.ndarray] = None diff --git a/energy_fault_detector/core/model_factory.py b/energy_fault_detector/core/model_factory.py index 4aef0bf..1aca17f 100644 --- a/energy_fault_detector/core/model_factory.py +++ b/energy_fault_detector/core/model_factory.py @@ -32,9 +32,11 @@ def _initialize_models(self) -> None: # Retrieve training configuration train_dict = self.config['train'] - # data preprocessor + # data preprocessor - not specified leads to a default pipeline + data_prep_conf = (train_dict.get('data_preprocessor', {}) or {}) self._models['data_preprocessor'] = DataPreprocessor( - **train_dict.get('data_preprocessor', {}).get('params', {}) + steps=data_prep_conf.get('steps'), + **data_prep_conf.get('params', {}) ) # Loop through each model type and initialize the corresponding model diff --git a/energy_fault_detector/data_preprocessing/column_selector.py b/energy_fault_detector/data_preprocessing/column_selector.py index 3274cbf..2c5ced3 100644 --- a/energy_fault_detector/data_preprocessing/column_selector.py +++ b/energy_fault_detector/data_preprocessing/column_selector.py @@ -1,4 +1,3 @@ - from typing import Optional, List import numpy as np @@ -14,7 +13,8 @@ class ColumnSelector(DataTransformer): Args: max_nan_frac_per_col: maximum fraction of NaN values allowed per column. Defaults to 0.05. If the fraction exceeds max_nan_frac_per_col, the column is dropped. - features_to_exclude: list of features that should be dropped. Defaults to None. + features_to_exclude: columns to drop (case-insensitive). + features_to_select: columns to keep (case-insensitive). Mutually exclusive with features_to_exclude. Attributes: feature_names_in_: list of column names in input. @@ -23,12 +23,20 @@ class ColumnSelector(DataTransformer): columns_dropped_: list of columns that were dropped. """ - def __init__(self, max_nan_frac_per_col: float = 0.05, features_to_exclude: List[str] = None): - + def __init__( + self, + max_nan_frac_per_col: float = 0.05, + features_to_exclude: Optional[List[str]] = None, + features_to_select: Optional[List[str]] = None, + ): super().__init__() - + if features_to_exclude is not None and features_to_select is not None: + raise ValueError("Only one of features_to_exclude or features_to_select can be specified.") + if not (0.0 <= max_nan_frac_per_col <= 1.0): + raise ValueError("max_nan_frac_per_col must be within [0, 1].") self.max_nan_frac_per_col: float = max_nan_frac_per_col self.features_to_exclude: List[str] = features_to_exclude if features_to_exclude is not None else [] + self.features_to_select: Optional[List[str]] = features_to_select # pylint: disable=attribute-defined-outside-init # noinspection PyAttributeOutsideInit @@ -43,11 +51,17 @@ def fit(self, x: pd.DataFrame, y: Optional[np.array] = None) -> 'ColumnSelector' self.feature_names_in_ = x.columns.to_list() self.n_features_in_ = len(x.columns) - # drop features to exclude - ignore upper/lower case - to_drop = [col for col in x.columns if col.lower() in - [excluded_feature.lower() for excluded_feature in self.features_to_exclude] - ] - x_transformed = x.drop(to_drop, axis=1, errors='ignore') + # If features_to_select is provided - ignore upper/lower case + if self.features_to_select is not None: + select_lower = [f.lower() for f in self.features_to_select] + keep_cols = [col for col in x.columns if col.lower() in select_lower] + x_transformed = x[keep_cols] + else: + # drop features to exclude - ignore upper/lower case + to_drop = [col for col in x.columns if col.lower() in + [excluded_feature.lower() for excluded_feature in self.features_to_exclude] + ] + x_transformed = x.drop(to_drop, axis=1, errors='ignore') # drop columns which have more than max_nan_frac_per_col relative NaN frequency empty_percentage = x_transformed.isnull().mean(axis=0) @@ -69,8 +83,11 @@ def transform(self, x: pd.DataFrame) -> pd.DataFrame: # transformation is not possible. missing_columns = set(self.feature_names_out_) - set(x.columns) if len(missing_columns) > 0: - raise ValueError('ColumnSelector: There are columns missing in the prediction data, which were present in' - ' the training data. New models need to be trained!') + raise ValueError( + 'ColumnSelector: There are columns missing in the prediction data, which were present in' + ' the training data. Missing columns: ' + f"{', '.join(sorted(missing_columns))}. New models need to be trained!" + ) x = x[self.feature_names_out_] # ensure ordering return x diff --git a/energy_fault_detector/data_preprocessing/counter_diff_transformer.py b/energy_fault_detector/data_preprocessing/counter_diff_transformer.py new file mode 100644 index 0000000..c56fb6e --- /dev/null +++ b/energy_fault_detector/data_preprocessing/counter_diff_transformer.py @@ -0,0 +1,322 @@ + +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd +from sklearn.utils.validation import check_is_fitted + +from energy_fault_detector.core.data_transformer import DataTransformer + + +class CounterDiffTransformer(DataTransformer): + """ + Transform monotonic counter columns into per-sample increments (default) or per-second rates (if compute_rate=True), + handling resets/rollovers and masking long time gaps. + + It handles counter resets/rollovers and optionally masks values after large time gaps, which helps avoid misleading + diffs/rates caused by missing data. + + Args: + counters: List of counter column names to transform. + compute_rate: If True, output per-second rates (increment / dt). If False (default), + output per-sample increments. + reset_strategy: One of {'zero', 'rollover', 'nan', 'auto'}: + + - 'zero' (default): if diff < 0, treat as reset-to-zero; increment = current_value. + - 'rollover': if diff < 0, increment = current_value + (rollover_value - previous_value). + - 'nan': if diff < 0, set increment to NaN. + - 'auto': use 'rollover' if rollover_values contains the counter; otherwise 'zero'. + + rollover_values: Optional mapping counter -> known max value (used by 'rollover' or 'auto'). + small_negative_tolerance: Treat small negative diffs (``abs(diff) <= tol``) as 0 (noise). Default: 0.0. + fill_first: One of {'nan', 'zero'}. How to fill the first sample where diff is undefined. + keep_original: If True, keep original counters alongside new outputs. If False, drop them. + gap_policy: One of {'mask', 'ignore'}: + + - 'mask' (default): set output to NaN for rows where time delta > threshold. + - 'ignore': do nothing special for large gaps. + + max_gap_seconds: Explicit threshold (in seconds) for gap masking. If provided, overrides + max_gap_factor. + max_gap_factor: If max_gap_seconds is None, use threshold = factor * median(dt). + Default is 3.0. + + Notes: + - A DatetimeIndex is required if compute_rate=True or gap_policy='mask'. + - The inverse_transform is a no-op and returns the input unchanged. + + Examples: + - Diffs: [0, 1, 3, 0 (reset), 2] -> [NaN|0, 1, 2, 0|NaN, 2] + - Rates: increment / dt (in seconds), with large-gap rows optionally masked to NaN. + """ + + def __init__( + self, + counters: List[str], + compute_rate: bool = False, + reset_strategy: str = "zero", + rollover_values: Optional[Dict[str, float]] = None, + small_negative_tolerance: float = 0.0, + fill_first: str = "nan", + keep_original: bool = False, + gap_policy: str = "mask", + max_gap_seconds: Optional[float] = None, + max_gap_factor: float = 3.0, + ) -> None: + super().__init__() + self.counters = counters or [] + self.compute_rate = compute_rate + self.reset_strategy = reset_strategy + self.rollover_values = rollover_values or {} + self.small_negative_tolerance = float(small_negative_tolerance) + self.fill_first = fill_first + self.keep_original = keep_original + self.gap_policy = gap_policy + self.max_gap_seconds = max_gap_seconds + self.max_gap_factor = float(max_gap_factor) + + def fit(self, x: pd.DataFrame, y: Optional[pd.Series] = None) -> "CounterDiffTransformer": + """Validate inputs and compute output schema. + + This method validates the time index (when needed), stores the list of counters that are + present in the input, and computes the output column layout such that transform() can + reproduce the same order deterministically. + + Args: + x: Input DataFrame. Requires a DatetimeIndex if compute_rate=True or gap_policy='mask'. + y: Unused. Present for estimator interface compatibility. + + Returns: + self + + Raises: + ValueError: If a DatetimeIndex is required but missing or non-monotonic. + """ + self.feature_names_in_ = x.columns.to_list() + self.n_features_in_ = len(x.columns) + + # DatetimeIndex is required for rates or for gap masking + if self.compute_rate or self.gap_policy == "mask": + if not isinstance(x.index, pd.DatetimeIndex): + raise ValueError( + "CounterDiffTransformer: DatetimeIndex required (rate or gap masking)." + ) + if not x.index.is_monotonic_increasing: + raise ValueError("CounterDiffTransformer: index must be monotonic increasing.") + + # Keep only counters present in the DataFrame + self.counters_ = [c for c in self.counters if c in self.feature_names_in_] + + # Determine output suffix + self.output_suffix_ = "_rate" if self.compute_rate else "_diff" + + # Compose output feature order + new_cols = [f"{c}{self.output_suffix_}" for c in self.counters_] + if self.keep_original: + # Append new output columns after all original features + self.feature_names_out_ = list(self.feature_names_in_) + new_cols + else: + # Keep non-counter features first, then the new output columns + others = [col for col in self.feature_names_in_ if col not in self.counters_] + self.feature_names_out_ = others + new_cols + + # Track columns dropped when keep_original is False (for introspection/testing) + self.columns_dropped_ = [] if self.keep_original else [c for c in self.counters_] + return self + + def _time_deltas_seconds(self, x: pd.DataFrame) -> Optional[pd.Series]: + """Compute per-row time delta in seconds, or None if not needed. + + Returns NaN for the first row and when dt is 0 seconds (zero dt is masked to NaN to avoid + division by zero for rate calculations). + + Args: + x: Input DataFrame. + + Returns: + A Series of dt seconds aligned to x.index, or None if neither rate nor masking is used. + + Raises: + ValueError: If a DatetimeIndex is required but missing or non-monotonic. + """ + if not (self.compute_rate or self.gap_policy == "mask"): + return None + if not isinstance(x.index, pd.DatetimeIndex): + raise ValueError("CounterDiffTransformer: DatetimeIndex required for rate or gap masking.") + if not x.index.is_monotonic_increasing: + raise ValueError("CounterDiffTransformer: index must be monotonic increasing.") + + # Create a series of timestamps to keep the original index for alignment + dt = pd.Series(x.index, index=x.index).diff().dt.total_seconds() + # Prevent division by zero when computing rates + dt = dt.mask(dt == 0, np.nan) + return dt + + def _gap_threshold(self, dt: pd.Series) -> Optional[float]: + """Compute the gap masking threshold in seconds, or None if masking disabled. + + Args: + dt: Series of time deltas in seconds. + + Returns: + Threshold in seconds or None if masking is not applicable. If max_gap_seconds is given, + it is used; otherwise threshold = max_gap_factor * median(dt). If median is not finite + or <= 0, returns None and masking is effectively disabled. + """ + if self.gap_policy != "mask" or dt is None: + return None + if self.max_gap_seconds is not None: + return float(self.max_gap_seconds) + + med = float(np.nanmedian(dt.values)) if len(dt) else np.nan + if not np.isfinite(med) or med <= 0: + return None + return self.max_gap_factor * med + + def _compute_increment( + self, + s: pd.Series, + strategy: str, + rollover_value: Optional[float], + ) -> pd.Series: + """Compute per-sample increment for a counter series with reset handling. + + This applies the selected reset strategy to negative diffs and also clamps small negative + diffs (within small_negative_tolerance) to zero to mitigate minor noise/clock skew. + + Args: + s: Input counter Series. + strategy: Reset strategy ('zero', 'rollover', 'nan', 'auto'). + rollover_value: Known rollover maximum (used by 'rollover' or 'auto'). + + Returns: + Series of increments aligned to s.index, with the first element filled according to + fill_first ('zero' or 'nan'). + + Raises: + ValueError: If series contains non-numeric values (excluding existing NaNs), + or if strategy is 'rollover' but rollover_value is None, + or if an unknown reset strategy is provided. + """ + # Try to coerce to numeric; if this introduces new NaNs (beyond existing ones), error out + v = pd.to_numeric(s, errors="coerce") + if v.isna().sum() > s.isna().sum(): + raise ValueError( + "CounterDiffTransformer: non-numeric values found in counter series. " + "Ensure all counter values are numeric or NaN." + ) + prev = v.shift(1) + diff = v.diff() + + # Clamp small negative diffs to zero (treat as noise) + if self.small_negative_tolerance > 0: + small_neg = (diff < 0) & ((-diff) <= self.small_negative_tolerance) + diff = diff.mask(small_neg, 0.0) + + neg_mask = diff < 0 + + # Map 'auto' to a concrete strategy + if strategy == "auto": + strategy = "rollover" if rollover_value is not None else "zero" + + if strategy == "nan": + inc = diff.mask(neg_mask, np.nan) + elif strategy == "zero": + # Treat reset-to-zero as increment equals current value + inc = diff.where(~neg_mask, v) + elif strategy == "rollover": + if rollover_value is None: + # Explicit 'rollover' requires a value. + raise ValueError( + "CounterDiffTransformer: rollover strategy requires a rollover_value for the " + f"counter '{s.name}'. Use reset_strategy='auto' to fallback to 'zero' when not provided." + ) + # Add the wrapped amount: current + (rollover - previous) + inc = diff.where(~neg_mask, v + (rollover_value - prev)) + else: + raise ValueError(f"CounterDiffTransformer: unknown reset_strategy '{strategy}'") + + return inc + + def transform(self, x: pd.DataFrame) -> pd.DataFrame: + """Transform counters into diffs or rates, with optional gap masking. + + For each configured counter: + 1) Compute per-sample increment with reset handling. + 2) If compute_rate=True, divide by dt seconds. + 3) If gap_policy='mask', set values to NaN where dt > gap_threshold. + + Args: + x: Input DataFrame. Requires a DatetimeIndex if compute_rate=True or gap_policy='mask'. + + Returns: + A DataFrame with transformed columns appended (if keep_original=True) or replacing the + original counters (if keep_original=False). Column order matches fit()'s schema. + + Raises: + ValueError: If DatetimeIndex is required but missing or non-monotonic. + """ + check_is_fitted(self) + x_ = x.copy() + + dt = self._time_deltas_seconds(x_) + gap_thr = self._gap_threshold(dt) if dt is not None else None + + new_cols = {} + for c in self.counters_: + increment = self._compute_increment( + x_[c], strategy=self.reset_strategy, rollover_value=self.rollover_values.get(c) + ) + series = (increment / dt) if self.compute_rate and dt is not None else increment + + # Ensure first sample respects fill_first setting + series.iloc[0] = 0.0 if self.fill_first == "zero" else np.nan + + # Mask large gaps for both diffs and rates to avoid misleading values + if gap_thr is not None: + series = series.mask(dt > gap_thr) + + new_cols[f"{c}{self.output_suffix_}"] = series + + # Attach new columns + for name, col in new_cols.items(): + x_[name] = col + + # Optionally remove original counter columns + if not self.keep_original: + x_ = x_.drop(columns=self.counters_, errors='ignore') + + # Reorder to the schema established during fit + x_ = x_[self.feature_names_out_] + return x_ + + def inverse_transform(self, x: pd.DataFrame) -> pd.DataFrame: + """If original counter columns are present, drop the derived columns and restore original feature order. + Otherwise, returns the input as is. + + Args: + x: Input DataFrame. + + Returns: + The input DataFrame unchanged. + """ + check_is_fitted(self) + x_ = x.copy() + orig_counters_present = all(c in x_.columns for c in self.counters_) + if orig_counters_present: + if all(col in x_.columns for col in self.feature_names_in_): + x_ = x_[self.feature_names_in_] + return x_ + return x + + def get_feature_names_out(self, input_features: Optional[List[str]] = None) -> List[str]: + """Return the output feature names determined in fit(). + + Args: + input_features: Unused. Present for compatibility with sklearn API. + + Returns: + List of output column names. + """ + check_is_fitted(self) + return self.feature_names_out_ diff --git a/energy_fault_detector/data_preprocessing/data_clipper.py b/energy_fault_detector/data_preprocessing/data_clipper.py index 3adf558..965c457 100644 --- a/energy_fault_detector/data_preprocessing/data_clipper.py +++ b/energy_fault_detector/data_preprocessing/data_clipper.py @@ -1,7 +1,7 @@ """Clip data before standardization or normalization""" import logging -from typing import Optional, List, Union +from typing import Optional, List import numpy as np import pandas as pd @@ -19,19 +19,42 @@ class DataClipper(DataTransformer): Args: lower_percentile (float): The lower percentile for clipping (default: 0.01). upper_percentile (float): The upper percentile for clipping (default: 0.99). - features_to_exclude (List[str]): A list of column names representing feature that should not be clipped. + features_to_exclude (List[str] | None): Column names that should not be clipped. + features_to_clip (List[str] | None): Column names that should be clipped (mutually exclusive with + features_to_exclude). + Configuration example: + + .. code-block:: text + + train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + features_to_exclude: + - do_not_clip_this_feature """ def __init__(self, lower_percentile: float = 0.01, upper_percentile: float = 0.99, - features_to_exclude: List[str] = None): + features_to_exclude: Optional[List[str]] = None, features_to_clip: Optional[List[str]] = None) -> None: + super().__init__() + if features_to_clip is not None and features_to_exclude is not None: + raise ValueError('Only one of features_to_clip or features_to_exclude can be specified.') + if not (0.0 <= lower_percentile <= 1.0) or not (0.0 <= upper_percentile <= 1.0): + raise ValueError('Percentiles must be within [0, 1].') + if lower_percentile >= upper_percentile: + raise ValueError('lower_percentile must be strictly less than upper_percentile.') + self.lower_percentile = lower_percentile self.upper_percentile = upper_percentile - self.feature_to_exclude: List[str] = features_to_exclude if features_to_exclude is not None else [] + self.feature_to_exclude: Optional[List[str]] = features_to_exclude + self.features_to_clip: Optional[List[str]] = features_to_clip - def fit(self, x: Union[np.array, pd.DataFrame], y: Optional[np.array] = None) -> 'DataClipper': + def fit(self, x: pd.DataFrame, y: Optional[np.array] = None) -> 'DataClipper': """Set feature names in and out.""" + if not isinstance(x, pd.DataFrame): + raise TypeError('DataClipper.fit expects a pandas DataFrame.') self.feature_names_in_ = x.columns.to_list() self.feature_names_out_ = x.columns.to_list() return self @@ -48,11 +71,23 @@ def transform(self, x: pd.DataFrame) -> pd.DataFrame: """ check_is_fitted(self) - # Exclude columns representing angles + + # Select feature to clip x_ = x.copy() - x_without_feature_to_exclude = x_[[col for col in x_.columns if col not in self.feature_to_exclude]] + if self.feature_to_exclude is not None: + selected_features = [col for col in x_.columns if col not in self.feature_to_exclude] + elif self.features_to_clip is not None: + selected_features = [col for col in x_.columns if col in self.features_to_clip] + else: + # Clip all numeric columns + selected_features = x_.columns.tolist() + # Exclude non-numeric columns - x_numeric = x_without_feature_to_exclude.select_dtypes(include=np.number) + x_numeric = x_[selected_features].select_dtypes(include=np.number) + + if x_numeric.shape[1] == 0: + logger.debug('DataClipper.transform: no numeric columns selected; returning input unchanged.') + return x_ # Clip the data using the specified percentiles x_clipped = x_numeric.clip( diff --git a/energy_fault_detector/data_preprocessing/data_preprocessor.py b/energy_fault_detector/data_preprocessing/data_preprocessor.py index 5d0c809..8883b46 100644 --- a/energy_fault_detector/data_preprocessing/data_preprocessor.py +++ b/energy_fault_detector/data_preprocessing/data_preprocessor.py @@ -1,6 +1,8 @@ """Generic class for building a preprocessing pipeline.""" -from typing import List, Optional +from collections import Counter, defaultdict +from typing import List, Optional, Dict, Any, Tuple +import warnings import pandas as pd from sklearn.pipeline import Pipeline @@ -12,179 +14,498 @@ from energy_fault_detector.data_preprocessing.low_unique_value_filter import LowUniqueValueFilter from energy_fault_detector.data_preprocessing.angle_transformer import AngleTransformer from energy_fault_detector.data_preprocessing.duplicate_value_to_nan import DuplicateValuesToNan +from energy_fault_detector.data_preprocessing.counter_diff_transformer import CounterDiffTransformer class DataPreprocessor(Pipeline, SaveLoadMixin): - """A data preprocessing pipeline that allows for configurable steps based on the extended pipeline. - - 0. (optional) Replace any consecutive duplicate zero-values (or another value) with NaN. This step should be - used if 0 can also represent missing values in the data. - 1. (optional) Column selection: A ColumnSelector object filters out columns/features with too many NaN values. - 2. (optional) Features containing angles are transformed to sine/cosine values. - 3. (optional) Low unique value filter: Remove columns/features with a low number of unique values or - high fraction of zeroes. The high fraction of zeros setting should be used if 0 can also represent missing - values in the data. - 4. Imputation with sklearn's SimpleImputer - 5. Scaling: Apply either sklearn's StandardScaler or MinMaxScaler. - - Args: - angles: List of angle features for transformation. Defaults to None. - If none provided (or empty list), this step is skipped. - imputer_strategy: Strategy for imputation ('mean', 'median', 'most_frequent', 'constant'). Defaults to 'mean'. - imputer_fill_value: Value to fill for imputation (if imputer_strategy=='constant'). - scale: Type of scaling ('standardize' or 'normalize'). Defaults to 'standardize'. - include_column_selector: Whether to include the column selector step. Defaults to True. - features_to_exclude: ColumnSelector option, list of features to exclude from processing. - max_nan_frac_per_col: ColumnSelector option, max fraction of NaN values allowed per column. Defaults to 0.05. - include_low_unique_value_filter: Whether to include the low unique value filter step. Defaults to True. - min_unique_value_count: Minimum number of unique values for low unique value filter. Defaults to 2. - max_col_zero_frac: Maximum fraction of zeroes for low unique value filter. Defaults to 1.0. - include_duplicate_value_to_nan: Whether to include the duplicate value replacement step. Defaults to False. - value_to_replace: Value to replace with NaN (if using duplicate value replacement). Defaults to None. - n_max_duplicates: Max number of consecutive duplicates to replace with NaN. Defaults to 144. - - Configuration example: - - .. code-block:: text - - train: - data_preprocessor: - params: - scale: normalize - imputer_strategy: mean - max_nan_frac_per_col: 0.05 - include_low_unique_value_filter: true - min_unique_value_count: 2 - max_col_zero_frac: 0.99 - angles: - - angle1 - - angle2 - features_to_exclude: - - feature1 - - feature2 - """ - - def __init__(self, - angles: Optional[List[str]] = None, - imputer_strategy: str = 'mean', - imputer_fill_value: Optional[int] = None, - scale: str = 'standardize', - include_column_selector: bool = True, - features_to_exclude: Optional[List[str]] = None, - max_nan_frac_per_col: float = 0.05, - include_low_unique_value_filter: bool = True, - min_unique_value_count: int = 2, - max_col_zero_frac: float = 1., - include_duplicate_value_to_nan: bool = False, - value_to_replace: float = 0, - n_max_duplicates: int = 144, - duplicate_features_to_exclude: Optional[List[str]] = None - ): - - self.include_column_selector = include_column_selector - self.features_to_exclude = features_to_exclude - self.max_nan_frac_per_col = max_nan_frac_per_col - - self.angles = angles - - self.include_low_unique_value_filter = include_low_unique_value_filter - self.min_unique_value_count = min_unique_value_count - self.max_col_zero_frac = max_col_zero_frac - - self.imputer_strategy = imputer_strategy - self.imputer_fill_value = imputer_fill_value - - self.scale = scale - - self.include_duplicate_value_to_nan = include_duplicate_value_to_nan - self.value_to_replace = value_to_replace - self.n_max_duplicates = n_max_duplicates - self.duplicate_features_to_exclude = duplicate_features_to_exclude - - # Define the scaler based on the chosen scale type - scaler = (StandardScaler(with_mean=True, with_std=True) - if scale in ['standardize', 'standard', 'standardscaler'] - else MinMaxScaler(feature_range=(0, 1))) - - # Configure the pipeline steps - steps = [] - - if include_duplicate_value_to_nan: - steps.append( - ('value_to_nan', - # Do not open source, very specific to our data problems - DuplicateValuesToNan(value_to_replace=value_to_replace, n_max_duplicates=n_max_duplicates, - features_to_exclude=duplicate_features_to_exclude)) - ) - if include_column_selector: - steps.append( - ('column_selector', - ColumnSelector(max_nan_frac_per_col=max_nan_frac_per_col, features_to_exclude=features_to_exclude)) - ) - if include_low_unique_value_filter: - steps.append( - ('low_unique_value_filter', - LowUniqueValueFilter(min_unique_value_count=min_unique_value_count, max_col_zero_frac=max_col_zero_frac)) - ) - if angles is not None and len(angles) > 0: - steps.append(('angle_transform', AngleTransformer(angles=angles))) + STEP_REGISTRY = { + 'duplicate_to_nan': DuplicateValuesToNan, + 'column_selector': ColumnSelector, + 'low_unique_value_filter': LowUniqueValueFilter, + 'angle_transformer': AngleTransformer, + 'counter_diff_transformer': CounterDiffTransformer, + 'simple_imputer': SimpleImputer, + 'standard_scaler': StandardScaler, + 'minmax_scaler': MinMaxScaler, + } + + NAME_ALIASES: Dict[str, str] = { + "angle_transform": "angle_transformer", + "counter_diff": "counter_diff_transformer", + "counter_diff_transform": "counter_diff_transformer", + "standardize": "standard_scaler", + "standard": "standard_scaler", + "standardscaler": "standard_scaler", + "minmax": "minmax_scaler", + "imputer": "simple_imputer", + "duplicate_value_to_nan": "duplicate_to_nan", + "duplicate_values_to_nan": "duplicate_to_nan", + } + + def __init__(self, steps: Optional[List[Dict[str, Any]]] = None, **params: Any) -> None: + """A data preprocessing pipeline that allows for configurable steps based on the extended pipeline. + + If both steps and legacy params are provided, steps take precedence and a warning is emitted. + When neither steps nor legacy params are provided, a default "old-style" pipeline is created which removes + features that are constant or just binary and contain more 5% missing values. Afterward, remaining missing + values are imputed with the mean and the features are scaled with the StandardScaler. - # default steps: - steps.append(('imputer', SimpleImputer(strategy=imputer_strategy, - fill_value=imputer_fill_value).set_output(transform='pandas'))) - steps.append(('scaler', scaler)) - - super().__init__(steps=steps) - self.set_output(transform="pandas") # set output of all transformers to pandas + Args: + steps: Optional list of step specifications. Each item is a dict with: + + - name: registered step name (see STEP_REGISTRY). + - enabled: optional bool (default True). + - params: dict of constructor arguments for the step. + - step_name: optional explicit pipeline name (defaults to name). + + **params: Legacy parameters used when steps is None (see _legacy_keys()). + + Notes: + Enforced ordering in steps mode: + + 1) NaN introducing steps first (DuplicateValuesToNan, CounterDiffTransformer), + 2) ColumnSelector (if present), + 3) Other steps + 4) SimpleImputer placed before scaler (always present; mean strategy by default), + 5) Scaler always last (StandardScaler by default). + + Configuration example: + + .. code-block:: text + + train: + data_preprocessor: + steps: + - name: column_selector + params: + max_nan_frac_per_col: 0.05 + features_to_exclude: ['exclude_this_feature'] + - name: counter_diff_transformer + step_name: counter_flow + params: + counters: ['flow_total_m3'] + compute_rate: True + fill_first: 'zero' + - name: counter_diff_transformer + step_name: counter_energy + params: + counters: ['energy_total_kwh'] + compute_rate: False + fill_first: 'zero' + reset_strategy: 'rollover', + rollover_values: + 'energy_total_kwh': 100000.0 + """ - def inverse_transform(self, x: pd.DataFrame, **kwargs) -> pd.DataFrame: - """Reverses the scaler and angle transforms applied to the data. - Other transformations are not reversed. + self.steps_spec_: Optional[List[Dict[str, Any]]] = steps + self.params_: Dict[str, Any] = dict(params) + + if steps is not None and len(steps) > 0: + # Warn if legacy params are present alongside steps. + legacy_keys = set(self._legacy_keys()) + legacy_used = [k for k in self.params_.keys() if k in legacy_keys] + if legacy_used: + warnings.warn( + f"DataPreprocessor: 'steps' provided; legacy params are ignored: {legacy_used}", + UserWarning + ) + built_steps = self._build_from_steps_spec() + else: + # Build the default or legacy pipeline. If params is empty, defaults are applied. + built_steps = self._build_from_legacy() + + super().__init__(steps=built_steps) + # Ensure pandas output for supported transformers. + self.set_output(transform="pandas") + + def inverse_transform(self, x: pd.DataFrame, **kwargs: Any) -> pd.DataFrame: + """Inverse-transform scaler and angles (other transforms are not reversed). Args: x: The transformed data. Returns: - A DataFrame with the inverse transformed data. + DataFrame with inverse scaling and angle back-transformation. """ + # Find scaler by type + scaler_key, _ = self._find_step_by_type((StandardScaler, MinMaxScaler)) + x_ = self.named_steps[scaler_key].inverse_transform(x.copy()) + x_ = pd.DataFrame(data=x_, columns=self.named_steps[scaler_key].get_feature_names_out()) - x_ = self.named_steps['scaler'].inverse_transform(x.copy()) - x_ = pd.DataFrame(data=x_, columns=self.named_steps['scaler'].get_feature_names_out()) - if 'angle_transform' in self.named_steps: - x_ = self.named_steps['angle_transform'].inverse_transform(x_) + # AngleTransformer supports inverse_transform; apply if present. + angle_key, _ = self._find_step_by_type((AngleTransformer,)) + if angle_key is not None: + x_ = self.named_steps[angle_key].inverse_transform(x_) + # Keep original index (important for time series). if isinstance(x, pd.DataFrame): - # ensure the index is kept x_.index = x.index - return x_ # pylint: disable=arguments-renamed - def transform(self, x: pd.DataFrame, **kwargs) -> pd.DataFrame: - """Transforms the input DataFrame using the pipeline. + def transform(self, x: pd.DataFrame, **kwargs: Any) -> pd.DataFrame: + """Apply pipeline steps to the input DataFrame. Args: x: Input DataFrame. Returns: - a dataframe with the same index as the input dataframe. + DataFrame with the same index as input. """ - x_ = super().transform(X=x.copy()) - return pd.DataFrame(data=x_, - columns=self.get_feature_names_out(), - index=x.index) + return pd.DataFrame(data=x_, columns=self.get_feature_names_out(), index=x.index) # pylint: disable=arguments-renamed - def fit_transform(self, x: pd.DataFrame, **kwargs) -> pd.DataFrame: - """Fit the model and transform with the final estimator. + def fit_transform(self, x: pd.DataFrame, **kwargs: Any) -> pd.DataFrame: + """Fit and transform in one step. Args: x: Input DataFrame. Returns: - Transformed DataFrame with the same index as the input dataframe. + Transformed DataFrame with the same index as input. """ - super().fit(X=x) return self.transform(x) + + def _find_step_by_type(self, types: Tuple[type, ...]) -> Tuple[Optional[str], Optional[object]]: + """Return the (step name, estimator) of the first step matching any of the given types.""" + for name, est in self.named_steps.items(): + if isinstance(est, types): + return name, est + return None, None + + @staticmethod + def _legacy_keys() -> List[str]: + """Return the list of supported legacy parameter keys.""" + return [ + "angles", + "imputer_strategy", + "imputer_fill_value", + "scale", + "include_column_selector", + "features_to_exclude", + "max_nan_frac_per_col", + "include_low_unique_value_filter", + "min_unique_value_count", + "max_col_zero_frac", + "include_duplicate_value_to_nan", + "value_to_replace", + "n_max_duplicates", + "duplicate_features_to_exclude", + "counter_columns_to_transform", + ] + + def _normalize_name(self, name: str) -> str: + """Normalize a user-provided step name to a canonical registry key.""" + return self.NAME_ALIASES.get(name, name) + + @staticmethod + def _validate_singletons(steps_spec: List[Dict[str, Any]]) -> None: + """Ensure only one instance of selected steps is present (enabled ones).""" + singleton_names = { + "angle_transformer", + "column_selector", + "low_unique_value_filter", + "simple_imputer", + # scaler handled separately (standard_scaler/minmax_scaler) in your code + } + counts: List[Tuple[str, int]] = [] + for name in singleton_names: + n = sum(1 for s in steps_spec if s.get("enabled", True) and s.get("name") == name) + if n > 1: + counts.append((name, n)) + if counts: + raise ValueError( + "Each of these steps may appear at most once: " + f"{[n for n, _ in counts]}. Found duplicates: {counts}" + ) + + def _build_from_legacy(self) -> List: + """Build pipeline from legacy parameters (old behavior + enforced ordering). + + Steps: + 0. (optional) Replace any consecutive duplicate zero-values (or another value) with NaN. This step should be + used if 0 can also represent missing values in the data. + 1. (optional) Normalize counters to differences. + 2. (optional) Column selection: A ColumnSelector object filters out columns/features with too many NaN values. + 3. (optional) Low unique value filter: Remove columns/features with a low number of unique values or + high fraction of zeroes. The high fraction of zeros setting should be used if 0 can also represent missing + values in the data. + 4. (optional) Features containing angles are transformed to sine/cosine values. + 5. Imputation with sklearn's SimpleImputer + 6. Scaling: Apply either sklearn's StandardScaler or MinMaxScaler. + + Use legacy parameters passed via **params. If empty, defaults are used. + - angles: List of angle features for transformation. Default: None (skipped). + - imputer_strategy: Strategy for imputation ('mean', 'median', 'most_frequent', 'constant'). Default: 'mean'. + - imputer_fill_value: Value to fill for imputation (if imputer_strategy=='constant'). + - scale: Type of scaling ('standardize' or 'normalize'). Default: 'standardize'. + - include_column_selector: Whether to include the column selector step. Default: True. + - features_to_exclude: ColumnSelector option, list of features to exclude from processing. + - max_nan_frac_per_col: ColumnSelector option, max fraction of NaN values allowed per column. Default: 0.05. + - include_low_unique_value_filter: Whether to include the low unique value filter step. Default: True. + - min_unique_value_count: Minimum number of unique values for low unique value filter. Default: 2. + - max_col_zero_frac: Maximum fraction of zeroes for low unique value filter. Default: 1.0. + - include_duplicate_value_to_nan: Whether to include the duplicate value replacement step. Default: False. + - value_to_replace: Value to replace with NaN (if using duplicate value replacement). Default: None. + - n_max_duplicates: Max number of consecutive duplicates to replace with NaN. Default: 144. + - counter_columns_to_transform: List of counters to normalize to differences. Default: None (skipped). + + Returns: + List of (name, estimator) tuples for the pipeline. + """ + steps: List = [] + params = self.params_ + + # 0. Replace any consecutive duplicate zero-values (or another value) with NaN. + if params.get("include_duplicate_value_to_nan", False): + steps.append( + ( + "value_to_nan", + DuplicateValuesToNan(value_to_replace=params.get("value_to_replace", 0), + n_max_duplicates=params.get("n_max_duplicates", 144), + features_to_exclude=params.get("duplicate_features_to_exclude")), + ) + ) + # 1. (optional) Normalize counters to differences. + counter_cols = params.get("counter_columns_to_transform", []) + if len(counter_cols) > 0: + steps.append( + ( + "counter_diff", + CounterDiffTransformer( + counters=counter_cols, + compute_rate=False, + reset_strategy="zero", + rollover_values=None, + small_negative_tolerance=0.0, + fill_first="nan", + keep_original=False, + gap_policy="mask", + max_gap_seconds=None, + max_gap_factor=3.0, + ), + ) + ) + # 2. ColumnSelector (default enabled) + if params.get("include_column_selector", True): + steps.append( + ( + "column_selector", + ColumnSelector(max_nan_frac_per_col=params.get("max_nan_frac_per_col", 0.05), + features_to_exclude=params.get("features_to_exclude")), + ) + ) + # 3. Optional value filters and angle transforms (before imputer) + if params.get("include_low_unique_value_filter", True): + steps.append( + ( + "low_unique_value_filter", + LowUniqueValueFilter( + min_unique_value_count=params.get("min_unique_value_count", 2), + max_col_zero_frac=params.get("max_col_zero_frac", 1.0), + ), + ) + ) + # 4. Apply optional angle transformations + angles = params.get("angles", []) + if len(angles) > 0: + steps.append(("angle_transform", AngleTransformer(angles=angles))) + # 5. Impute missing values with SimpleImputer + steps.append( + ( + "simple_imputer", + SimpleImputer( + strategy=params.get("imputer_strategy", "mean"), + fill_value=params.get("imputer_fill_value", None), + ).set_output(transform="pandas"), + ) + ) + # 6. Scale data + scale = params.get("scale", "standardize") + scaler = ( + StandardScaler(with_mean=True, with_std=True) + if scale in ["standardize", "standard", "standardscaler"] + else MinMaxScaler(feature_range=(0, 1)) + ) + steps.append(("scaler", scaler)) + return steps + + def _build_from_steps_spec(self) -> List: + """Build pipeline from steps specification (preferred mode) with enforced ordering. + + Each step has the following keys: + - name: registered step name (see STEP_REGISTRY). + - enabled: optional, defaults to True. + - params: dict of constructor parameters for the step. + - step_name: optional explicit pipeline key (defaults to name). + + Returns: + List of (name, estimator) tuples for the pipeline. + + Raises: + ValueError: If a step lacks 'name' or references an unknown step. + """ + self._validate_step_spec_keys(self.steps_spec_) + # Filter disabled steps first to simplify ordering. + enabled_spec = [s for s in self.steps_spec_ if s.get("enabled", True)] + self._validate_singletons(enabled_spec) + ordered_spec = self._order_steps_spec(enabled_spec) + # Assign unique step names for duplicates or missing step_name + ordered_spec = self._assign_unique_step_names(ordered_spec) + + steps: List = [] + scaler_defined = False + scaler_names = {"standard_scaler", "minmax_scaler"} + scaler_idx = None + for step_idx, spec in enumerate(ordered_spec): + name = spec.get("name") + if name is None: + raise ValueError("Each step spec requires a 'name'.") + if name in scaler_names: + scaler_defined = True + scaler_idx = step_idx + params = spec.get("params", {}) + cls = self.STEP_REGISTRY.get(name) + if cls is None: + raise ValueError(f"Unknown step name '{name}'. Register it in STEP_REGISTRY.") + estimator = cls(**params) + step_name = spec.get("step_name", name) + steps.append((step_name, estimator)) + + # Ensure an Imputer exists and is placed before the scaler. + if not any(n == "simple_imputer" for n, _ in steps): + default_imputer = SimpleImputer(strategy="mean").set_output(transform="pandas") + # Insert before scaler if scaler already present; else append. + if scaler_idx is not None: + steps.insert(scaler_idx, ("simple_imputer", default_imputer)) + else: + steps.append(("simple_imputer", default_imputer)) + + # Ensure a scaler exists and is last. If missing, add StandardScaler by default. + if not scaler_defined: + steps.append(("scaler", StandardScaler(with_mean=True, with_std=True))) + + return steps + + def _order_steps_spec(self, steps_spec: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Normalize ordering rules for a steps specification. + + Rules: + - NaN introducing steps first (DuplicateValuesToNan and CounterDiffTransformer) + - ColumnSelector (if present). + - Other steps + - Any imputer placed at the end, before scaler. If no imputer was defined, the SimpleImputer with imputation + strategy 'mean' is added. + - Scaler last (if present). If no scaler is added, the StandardScaler with default values is added. + + Args: + steps_spec: List of step dictionaries. + + Returns: + Reordered list of step dictionaries. + """ + # Normalize names to canonical keys for grouping + for s in steps_spec: + s["name"] = self._normalize_name(s.get("name")) + + # Separate groups by type for easy reassembly. + column_selector = [s for s in steps_spec if s.get("name") == "column_selector"] + low_unique_value_filter = [s for s in steps_spec if s.get("name") == "low_unique_value_filter"] + duplicates = [s for s in steps_spec if s.get("name") == "duplicate_to_nan"] + counter = [s for s in steps_spec if s.get("name") == "counter_diff_transformer"] + imputer = [s for s in steps_spec if s.get("name") == "simple_imputer"] + scaler_names = {"standard_scaler", "minmax_scaler"} + scalers = [s for s in steps_spec if s.get("name") in scaler_names] + if len(scalers) > 1: + raise ValueError("Only one scaler can be used, two found in the steps specification: ." + f"{scalers}") + others = [ + s for s in steps_spec + if s.get("name") not in { + "column_selector", "duplicate_to_nan", "counter_diff_transformer", "simple_imputer", + "low_unique_value_filter", + } | scaler_names + ] + + # Keep 'others' in their original relative order. + ordered = [] + # can add NaN avalues or add new features that may be constant + ordered.extend(duplicates) + ordered.extend(counter) + # drop columns based on the values (NaNs, no variance) + ordered.extend(column_selector) + ordered.extend(low_unique_value_filter) + # other transformations + ordered.extend(others) + # end with imputation and scaling + ordered.extend(imputer) # may be empty; scaler gets default added later if missing + ordered.extend(scalers) # may be empty; scaler gets default added later if missing + return ordered + + @staticmethod + def _assign_unique_step_names(specs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Assign unique pipeline step names. If step_name is provided, use it; if it collides, append _2, _3, ... + If step_name is not provided, use the 'name' key. If this key occurs multiple times, assign name_1, name_2, ... + + This method mutates specs in place and also returns it. + + Returns: + Altered step specifications. + """ + total_counts = Counter(s["name"] for s in specs) + used: set[str] = set() + per_base_index = defaultdict(int) + + for s in specs: + explicit = s.get("step_name") + if explicit: + base = explicit + candidate = base + i = 1 + while candidate in used: + i += 1 + candidate = f"{base}_{i}" + s["step_name"] = candidate + used.add(candidate) + continue + + base = s["name"] + if total_counts[base] == 1 and base not in used: + candidate = base + else: + per_base_index[base] += 1 + candidate = f"{base}_{per_base_index[base]}" + while candidate in used: + per_base_index[base] += 1 + candidate = f"{base}_{per_base_index[base]}" + + s["step_name"] = candidate + used.add(candidate) + + return specs + + @staticmethod + def _validate_step_spec_keys(steps_spec: List[Dict[str, Any]]) -> None: + """Validate that each step spec uses only allowed keys and includes 'name'. + + Allowed keys: {'name', 'enabled', 'params', 'step_name'}. + + Args: + steps_spec: Raw steps specification provided by the user. + + Raises: + ValueError: If a step is missing 'name' or contains unknown keys. + """ + allowed = {"name", "enabled", "params", "step_name"} + + for i, spec in enumerate(steps_spec): + if "name" not in spec: + raise ValueError(f"Step #{i} is missing required key 'name'.") + unknown = set(spec.keys()) - allowed + if unknown: + raise ValueError( + f"Step #{i} has unknown keys: {sorted(unknown)}. " + f"Allowed keys are: {sorted(allowed)}." + ) diff --git a/energy_fault_detector/data_preprocessing/low_unique_value_filter.py b/energy_fault_detector/data_preprocessing/low_unique_value_filter.py index b910047..3502634 100644 --- a/energy_fault_detector/data_preprocessing/low_unique_value_filter.py +++ b/energy_fault_detector/data_preprocessing/low_unique_value_filter.py @@ -14,9 +14,9 @@ class LowUniqueValueFilter(DataTransformer): exceeds `max_col_zero_frac`. Args: - min_unique_value_count (int): Minimum number of unique values a feature should have. Defaults to 2. - If set to 2, only constant features are dropped. - max_col_zero_frac (float): Maximum fraction of zeroes a column may contain. + min_unique_value_count (int): Minimum number of unique values a feature should have. Default: 2. + If set to 2, only constant features are dropped. + max_col_zero_frac (float): Maximum fraction of zeroes a column may contain. Default: 1.0 Attributes: feature_names_in_ (list): List of column names in input. @@ -25,7 +25,7 @@ class LowUniqueValueFilter(DataTransformer): columns_dropped_ (list): List of columns that were dropped during filtering. """ - def __init__(self, min_unique_value_count: int = 1, max_col_zero_frac: float = 0.9): + def __init__(self, min_unique_value_count: int = 2, max_col_zero_frac: float = 1.0): super().__init__() self.min_unique_value_count: int = min_unique_value_count diff --git a/energy_fault_detector/evaluation/__init__.py b/energy_fault_detector/evaluation/__init__.py index f136890..499c600 100644 --- a/energy_fault_detector/evaluation/__init__.py +++ b/energy_fault_detector/evaluation/__init__.py @@ -1,4 +1,5 @@ """Evaluation classes and methods, including the CARE-Score and Care2CompareDataset.""" -from energy_fault_detector.evaluation.care_score import CAREScore -from energy_fault_detector.evaluation.care2compare import Care2CompareDataset +from .care_score import CAREScore +from .care2compare import Care2CompareDataset +from .predist_dataset import PreDistDataset diff --git a/energy_fault_detector/evaluation/care2compare.py b/energy_fault_detector/evaluation/care2compare.py index 11b05cc..6fcfda9 100644 --- a/energy_fault_detector/evaluation/care2compare.py +++ b/energy_fault_detector/evaluation/care2compare.py @@ -18,24 +18,22 @@ class Care2CompareDataset: The data can be downloaded either manually from https://doi.org/10.5281/zenodo.14958989 (in this case specify `path`) or it can be downloaded automatically by setting download_dataset to True. - All data is loaded into memory, which might be problematic for large datasets (consider using DataLoader classes of - TensorFlow and PyTorch in that case). - By default, only the averages are read. See statistics argument of the data loading methods. - Methods: - get_event_info: Returns event info for a given event ID - iter_datasets: Reads datasets and yields the resulting training and test DataFrames while iterating over - event IDs. - format_event_dataset: Extracts normal_index from a loaded dataset and returns normal_index and sensor_data. - iter_formatted_datasets: Reads datasets, extracts normal_index and yields the resulting train and test - DataFrames as well as the normal_indexes while iterating over event IDs. - load_event_dataset: Reads dataset specified by event_id and returns training and test data. - load_and_format_event_dataset: Reads dataset specified by event_id and returns training and test data as well as - the corresponding normal indexes. - iter_train_datasets_per_asset: Reads datasets and yields the resulting training DataFrames while - iterating over asset IDs and aggregating event IDs for the same assets. - update_c2c_config: Updates a specified FaultDetector config based on provided feature descriptions. + Method overview: + + - get_event_info: Returns event info for a given event ID + - iter_datasets: Reads datasets and yields the resulting training and test DataFrames while iterating over + event IDs. + - format_event_dataset: Extracts normal_index from a loaded dataset and returns normal_index and sensor_data. + - iter_formatted_datasets: Reads datasets, extracts normal_index and yields the resulting train and test + DataFrames as well as the normal_indexes while iterating over event IDs. + - load_event_dataset: Reads dataset specified by event_id and returns training and test data. + - load_and_format_event_dataset: Reads dataset specified by event_id and returns training and test data as well as + the corresponding normal indexes. + - iter_train_datasets_per_asset: Reads datasets and yields the resulting training DataFrames while + iterating over asset IDs and aggregating event IDs for the same assets. + - update_c2c_config: Updates a specified FaultDetector config based on provided feature descriptions. Args: path (Path): The directory path where the dataset is located. @@ -127,12 +125,14 @@ def iter_formatted_datasets(self, wind_farm: str = None, test_only: bool = False statistics=statistics, index_column=index_column, use_readable_columns=use_readable_columns): if not test_only: - train_sensor_data, train_normal_index = self.format_event_dataset(tup[0]) - test_sensor_data, test_normal_index = self.format_event_dataset(tup[1]) - yield train_sensor_data, train_normal_index, test_sensor_data, test_normal_index, tup[2] + (x_train, x_test), event_id = tup + train_sensor_data, train_normal_index = self.format_event_dataset(x_train) + test_sensor_data, test_normal_index = self.format_event_dataset(x_test) + yield train_sensor_data, train_normal_index, test_sensor_data, test_normal_index, event_id else: - test_sensor_data, test_normal_index = self.format_event_dataset(tup[0]) - yield test_sensor_data, test_normal_index, tup[1] + x_test, event_id = tup + test_sensor_data, test_normal_index = self.format_event_dataset(x_test) + yield test_sensor_data, test_normal_index, event_id def load_event_dataset(self, event_id: int, test_only: bool = False, statistics: List[str] = None, index_column: str = 'id', use_readable_columns: bool = True @@ -179,9 +179,10 @@ def load_and_format_event_dataset(self, event_id: int, statistics: List[str] = N Returns: Tuple[pd.DataFrame, pd.Series, pd.DataFrame, pd.Series]: - If test_only=False, yields a tuple of train_sensor_data, train_status, - test_sensor_data and test_status. - If test_only=True, yields a tuple of test_sensor_data and test_status. + + - If test_only=False, yields a tuple of train_sensor_data, train_status, test_sensor_data and test_status. + - If test_only=True, yields a tuple of test_sensor_data and test_status. + """ tup = self.load_event_dataset(event_id=event_id, test_only=test_only, statistics=statistics, index_column=index_column, use_readable_columns=use_readable_columns) @@ -265,6 +266,24 @@ def get_columns(feature_description_selection: pd.DataFrame) -> List[str]: columns.append(f'{row.sensor_name}_{stat}') return columns + def merge_unique(base: List[str], to_add: List[str]) -> List[str]: + """Merge two lists preserving order and removing duplicates.""" + seen = set() + out: List[str] = [] + for v in (base or []) + (to_add or []): + if v not in seen: + seen.add(v) + out.append(v) + return out + + def find_step(names: List[str]) -> dict | None: + """Find step by name specification.""" + for s in steps: + name = s.get('name') + if name in names: + return s + return None + feature_descriptions = self.feature_descriptions[wind_farm] angles = feature_descriptions.loc[feature_descriptions['is_angle']] to_exclude = feature_descriptions.loc[feature_descriptions['is_counter']] @@ -272,12 +291,28 @@ def get_columns(feature_description_selection: pd.DataFrame) -> List[str]: angle_columns = get_columns(angles) to_exclude_columns = get_columns(to_exclude) - config['train']['data_preprocessor']['params']['angles'] = ( - config['train']['data_preprocessor']['params'].get('angles', []) + angle_columns - ) - config['train']['data_preprocessor']['params']['features_to_exclude'] = ( - config['train']['data_preprocessor']['params'].get('features_to_exclude', []) + to_exclude_columns - ) + # old: + dp = config['train'].setdefault('data_preprocessor', {}) + params = dp.get('params') + steps = dp.get('steps') + if params: + params['angles'] = merge_unique(params.get('angles', []), angle_columns) + params['features_to_exclude'] = merge_unique(params.get('features_to_exclude', []), to_exclude_columns) + # new + else: + angle_step = find_step(['angle_transformer', 'angle_transform']) + if angle_step is None: + steps.append({'name': 'angle_transformer', 'params': {'angles': angle_columns}}) + else: + angle_params = angle_step.setdefault('params', {}) + angle_params['angles'] = merge_unique(angle_params.get('angles', []), angle_columns) + colsel_step = find_step(['column_selector']) + if colsel_step is None: + steps.append({'name': 'column_selector', 'params': {'features_to_exclude': to_exclude_columns}}) + else: + colsel_params = colsel_step.setdefault('params', {}) + colsel_params['features_to_exclude'] = merge_unique( + colsel_params.get('features_to_exclude', []), to_exclude_columns) config.update_config(config.config_dict) diff --git a/energy_fault_detector/evaluation/care_score.py b/energy_fault_detector/evaluation/care_score.py index 2357e5e..5d77172 100644 --- a/energy_fault_detector/evaluation/care_score.py +++ b/energy_fault_detector/evaluation/care_score.py @@ -27,7 +27,7 @@ class CAREScore: The CARE score combines Coverage, Accuracy, Reliability and Earliness to evaluate early fault-detection performance (see CARE to Compare: A Real-World Benchmark Dataset for Early Fault Detection in Wind Turbine Data, - https://doi.org/10.3390/data9120138). The goal of the CARE-Score is to evaluate the ability of a given model to + https://doi.org/10.3390/data9120138). The goal of the CARE-Score is to evaluate the ability of a given model to separate `normal behavior` from `actionable anomalies` (see glossary for definitions), that lead to a fault or indicate a fault. @@ -167,15 +167,17 @@ def evaluate_event(self, event_start: Union[int, pd.Timestamp], event_end: Union Returns: dict: Dictionary with computed metrics, e.g.: - { - 'event_id': int, - 'event_label': str, - 'weighted_score': float, - 'max_criticality': float, - 'f_beta_score': float or NaN, - 'accuracy': float, - 'tp': int, 'fp': int, 'tn': int, 'fn': int - } + .. code-block:: python + + { + 'event_id': int, + 'event_label': str, + 'weighted_score': float, + 'max_criticality': float, + 'f_beta_score': float or NaN, + 'accuracy': float, + 'tp': int, 'fp': int, 'tn': int, 'fn': int + } Raises: ValueError: If event_label is invalid, evaluate_until_event_end has an unknown value, @@ -184,7 +186,7 @@ def evaluate_event(self, event_start: Union[int, pd.Timestamp], event_end: Union Notes: - The function sorts inputs by index to ensure alignment. - If normal_index is provided, this also influences the criticality calculation: criticality does not change - if the expected behaviour is not normal. + if the expected behaviour is not normal. - If predicted_anomalies_event is empty, a ValueError is raised. - Use evaluate_until_event_end to control whether post-event predictions are considered. """ @@ -265,15 +267,17 @@ def get_final_score(self, event_selection: Optional[List[int]] = None, criticali score for anomaly events), average Accuracy (for normal events) and Reliability (eventwise F-score) using the configured weights. - If the average accuracy over all normal events < 0.5, CARE-score = average accuracy over all normal events - (worse than random guessing). - If no anomalies were detected, the CARE-score = 0. - Else, the CARE-score is calculated as: + - If the average accuracy over all normal events < 0.5, CARE-score = average accuracy over all normal events + (worse than random guessing). + - If no anomalies were detected, the CARE-score = 0. + - Else, the CARE-score is calculated as: - ( (average F-score over all anomaly events) * coverage_w - + (average weighted score over all anomaly events) * weighted_score_w - + (average accuracy over all normal events) * accuracy_w - + event wise F-score * eventwise_f_score_w ) / sum_of_weights + .. code-block:: text + + ( (average F-score over all anomaly events) * coverage_w + + (average weighted score over all anomaly events) * weighted_score_w + + (average accuracy over all normal events) * accuracy_w + + event wise F-score * eventwise_f_score_w ) / sum_of_weights where `sum_of_weights` = coverage_w + weighted_score_w + accuracy_w + eventwise_f_score_w. diff --git a/energy_fault_detector/evaluation/predist_dataset.py b/energy_fault_detector/evaluation/predist_dataset.py new file mode 100644 index 0000000..177bb4f --- /dev/null +++ b/energy_fault_detector/evaluation/predist_dataset.py @@ -0,0 +1,208 @@ +import pandas as pd +from pathlib import Path +from typing import Dict, Any, Union +import logging + +from ..utils.data_downloads import download_zenodo_data + +logger = logging.getLogger('energy_fault_detector') + + +class PreDistDataset: + """Loader and preprocessor for the PreDist dataset. + + The data can be downloaded either manually from https://doi.org/10.5281/zenodo.17522254 (in this case specify + `path`) or it can be downloaded automatically by setting download_dataset to True. + + Args: + path (Union[str, Path]): Path to the dataset root. + download_dataset (bool): If True, downloads the PreDist dataset from Zenodo. + + Attributes: + events (Dict[int, pd.DataFrame): preloaded events dataframe for each manufacturer. + """ + + FAULT_HOURS_AFTER = 24 + FAULT_HOURS_BEFORE = 48 + + def __init__(self, path: Union[str, Path], download_dataset: bool = False): + if download_dataset: + logger.info("Downloading PreDist dataset from Zenodo (10.5281/zenodo.17522254)...") + path = download_zenodo_data(identifier="10.5281/zenodo.17522254", dest=path, overwrite=False) + + self.root_path = Path(path) + + # preload events + self.events: Dict[int, pd.DataFrame] = { + 1: self._load_events(manufacturer=1), + 2: self._load_events(manufacturer=2) + } + + def _load_events(self, manufacturer: int, filter_efd: bool = True) -> pd.DataFrame: + """Loads and combines all events from faults.csv and normal_events.csv. + + Args: + manufacturer (int): Dataset 1 or 2. + filter_efd (bool): Whether to filter events with efd possible or not. Default: True. + + Returns: + Events as dataframe, with start and end based on the possible anomaly start and report date for faults and + based on event start and end for normal events. + """ + + m_path = self.root_path / f"Manufacturer {manufacturer}" + + faults = pd.read_csv(m_path / 'faults.csv', sep=';', parse_dates=[ + 'Possible anomaly start', 'Report date', 'Possible anomaly end', + 'Training start', 'Training end' + ], index_col='Event ID') + + normals = pd.read_csv(m_path / 'normal_events.csv', sep=';', parse_dates=[ + 'Event start', 'Event end', 'Training start', 'Training end' + ], index_col='Event ID') + + if filter_efd: + # Only filter faults where early fault detection is possible (from a data perspective) + faults = faults[faults['efd_possible']] + + faults['Event type'] = 'anomaly' + faults['Event end'] = faults['Report date'] # for easy data selection later on + normals['Event type'] = 'normal' + + return pd.concat([faults, normals]) + + def load_substation_data(self, manufacturer: int, substation_id: int) -> pd.DataFrame: + """Loads raw CSV, maps string values, and cleans indices.""" + file_path = self.root_path / f"Manufacturer {manufacturer}" / 'operational_data' / f"substation_{substation_id}.csv" + df = pd.read_csv(file_path, sep=';', index_col='timestamp', parse_dates=['timestamp'], low_memory=False) + df.index = df.index.tz_localize(None) + df = df.sort_index() + + # Mapping string values (EIN/AUS) to (1/0) + val_map = {'EIN': 1, 'AUS': 0} + status_cols = [c for c in df.columns + if any(x in c for x in ['s_hc1_heating_pump_status_setpoint', + 's_hc1.2_heating_pump_status', + 's_hc1.3_heating_pump_status', + 's_hc2_dhw_3-way_valve_status', + 's_dhw_3-way_valve_status', + 's_hc1.1_heating_pump_status'])] + for col in status_cols: + if col in df.columns: + df[col] = df[col].map(val_map).astype('Int32') + + # Map control unit mode to integer + mode_map = {'Nacht': -1, 'Standby': 0, 'Tag': 1} + for col in [c for c in df.columns if 'control_unit_mode' in c]: + df[col] = df[col].map(mode_map).astype('Int32') + + # Handle noisy outside temperature value for specific substations + # In these cases, the outside temperature is not known - the sensor value is just noise + if manufacturer == 2 and substation_id in [18, 61]: + df = df.drop(columns=['outdoor_temperature'], errors='ignore') + + return df[~df.index.duplicated(keep='first')] + + def create_normal_flag(self, data: pd.DataFrame, manufacturer: int, substation_id: int) -> pd.Series: + """Create a normal flag based on disturbances, so we can select normal behaviour for training models. + + Args: + data (pd.DataFrame): Dataframe containing sensor data for a specific substation. + manufacturer (int): Dataset 1 or 2. + substation_id (int): ID of the substation to load data from. + + Returns: + pd.Series: Normal flag (boolean) based on disturbances with the same timestamp index as data. + """ + + dist_path = self.root_path / f"Manufacturer {manufacturer}" / 'disturbances.csv' + disturbances = pd.read_csv(dist_path, sep=';', parse_dates=['Event start']) + disturbances = disturbances[disturbances['substation ID'] == substation_id] + + normal_flag = pd.Series(True, index=data.index) + + # 1. Mark known anomalies from events_df + events_df = self.events[manufacturer] + anoms = events_df[(events_df['substation ID'] == substation_id) & (events_df['Event type'] == 'anomaly')] + for _, row in anoms.iterrows(): + # If we do not know when an anomaly started, we mark FAULT_HOURS_BEFORE before report + start = (row['Possible anomaly start'] + if pd.notna(row['Possible anomaly start']) + else (row['Report date'] - pd.Timedelta(hours=self.FAULT_HOURS_BEFORE))) + # If anomaly end was not provided, add some time after the fault for maintenance + # (This does not happen, anomaly end is always provided in this dataset) + end = (row['Possible anomaly end'] + if pd.notna(row['Possible anomaly end']) + else (row['Report date'] + pd.Timedelta(hours=self.FAULT_HOURS_AFTER))) + normal_flag.loc[start:end] = False + + # remove faults from disturbances already marked by the events dataframe + faults_in_disturbances = disturbances[disturbances['type'] == 'fault'] + faults_in_event_data = faults_in_disturbances[faults_in_disturbances['Event start'].isin(anoms['Report date'])] + disturbances = disturbances[~disturbances.index.isin(faults_in_event_data.index)] + + # 2. Mark disturbances (tasks, activities and remaining faults) + for _, dist in disturbances.iterrows(): + # round to nearest 10 minutes to match timestamp index of the data + d_start = dist['Event start'].floor('10min') + if dist['type'] == 'fault': + normal_flag.loc[d_start - pd.Timedelta(hours=self.FAULT_HOURS_BEFORE): + d_start + pd.Timedelta(hours=self.FAULT_HOURS_AFTER)] = False + else: # task/activity: mark the full day as possibly anomalous behaviour + normal_flag.loc[d_start: d_start.normalize() + pd.Timedelta(days=1)] = False + + return normal_flag + + def get_event_data(self, manufacturer: int, event_id: int, max_training_days: int = 2*365) -> Dict[str, Any]: + """Extracts training and test slices for a specific event row (fault or normal). + """ + + # get info from event + event_row = self.events[manufacturer].loc[event_id] + substation_id = event_row['substation ID'] + train_start = event_row['Training start'] + train_end = event_row['Training end'] + event_end = event_row['Event end'] + event_type = event_row['Event type'].lower() + anomaly_end = event_row.loc['Possible anomaly end'] + + # Max 2 years of training data + train_start = max(train_start, train_end - pd.Timedelta(days=max_training_days)) + + data = self.load_substation_data(manufacturer, event_row['substation ID']) + + # Training data + train_data = data.loc[train_start:train_end] + + # Test data + if event_type == 'normal': + test_data = data.loc[train_end:event_end] + else: # anomaly + # By default, 7 days before report, add 2 days after report for visualisations + test_data = data.loc[event_end - pd.Timedelta(days=7):anomaly_end + pd.Timedelta(days=2)] + # Exception: event 67 of manufacturer 1 (3 months) + if event_id == 67 and manufacturer == 1: + test_data = data.loc[ + event_row['Possible anomaly start']:anomaly_end + pd.Timedelta(days=2) + ] + + # Drop columns that are missing in the evaluation period + eval_data = test_data.loc[:event_end] + eval_data = eval_data.dropna(how='all', axis=1) + train_data = train_data[eval_data.columns] + test_data = test_data[eval_data.columns] + + # Create normal behaviour indicator + train_normal_flag = self.create_normal_flag(data=train_data, + manufacturer=manufacturer, + substation_id=substation_id) + test_normal_flag = self.create_normal_flag(data=test_data, + manufacturer=manufacturer, + substation_id=substation_id) + return { + 'train_data': train_data, + 'test_data': test_data, + 'train_normal_flag': train_normal_flag, + 'test_normal_flag': test_normal_flag, + 'event_data': event_row, + } diff --git a/energy_fault_detector/fault_detector.py b/energy_fault_detector/fault_detector.py index 2e1432b..941e743 100644 --- a/energy_fault_detector/fault_detector.py +++ b/energy_fault_detector/fault_detector.py @@ -3,23 +3,24 @@ import logging from typing import Optional, Tuple, List from datetime import datetime -import os import warnings +from pathlib import Path import pandas as pd import numpy as np from tensorflow.keras.backend import clear_session from energy_fault_detector.core.fault_detection_model import FaultDetectionModel +from energy_fault_detector.core.fault_detection_result import FaultDetectionResult, ModelMetadata from energy_fault_detector.threshold_selectors import AdaptiveThresholdSelector from energy_fault_detector.data_preprocessing.data_preprocessor import DataPreprocessor from energy_fault_detector.data_preprocessing.data_clipper import DataClipper from energy_fault_detector.root_cause_analysis import Arcana from energy_fault_detector.config import Config -from energy_fault_detector._logs import setup_logging -from energy_fault_detector.core.fault_detection_result import FaultDetectionResult, ModelMetadata +from energy_fault_detector.core._logs import setup_logging + -setup_logging(os.path.join(os.path.dirname(__file__), 'logging.yaml')) +setup_logging(Path(__file__).parent / 'logging.yaml') logger = logging.getLogger('energy_fault_detector') @@ -41,7 +42,7 @@ class FaultDetector(FaultDetectionModel): save_timestamps: a list of string timestamps indicating when the model was saved. """ - def __init__(self, config: Optional[Config] = None, model_directory: str = 'fault_detector_model', + def __init__(self, config: Optional[Config] = None, model_directory: str | Path = 'fault_detector_model', model_subdir: Optional[str] = None): if model_subdir is not None: warnings.warn( @@ -52,11 +53,6 @@ def __init__(self, config: Optional[Config] = None, model_directory: str = 'faul ) super().__init__(config=config, model_directory=model_directory) - if config is None: - logger.debug('No configuration set. Load models and config from path with the `FaultDetector.load_models`' - ' method.') - else: - self._init_models() def preprocess_train_data(self, sensor_data: pd.DataFrame, normal_index: pd.Series, fit_preprocessor: bool = True ) -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series]: @@ -99,6 +95,10 @@ def preprocess_train_data(self, sensor_data: pd.DataFrame, normal_index: pd.Seri self.data_preprocessor.fit(x_normal) x_prepped = self.data_preprocessor.transform(x_normal) + + # Use float32 by default for performance, unless specified otherwise in config + x_prepped = x_prepped.astype(self.config.dtype) + return x_prepped, x, y def fit(self, sensor_data: pd.DataFrame, normal_index: pd.Series = None, save_models: bool = True, @@ -287,6 +287,7 @@ def predict(self, sensor_data: pd.DataFrame, model_path: Optional[str] = None, logger.debug('No model_path provided; using existing model instances.') x_prepped = self.data_preprocessor.transform(x).sort_index() + x_prepped = x_prepped.astype(self.config.dtype) column_order = x_prepped.columns if self.autoencoder.is_conditional: diff --git a/energy_fault_detector/main.py b/energy_fault_detector/main.py index 3f379dd..563d9fa 100644 --- a/energy_fault_detector/main.py +++ b/energy_fault_detector/main.py @@ -1,19 +1,19 @@ """Quick energy fault detector CLI tool, to try out the EnergyFaultDetector model on a specific dataset.""" -import os import argparse import logging import yaml +from pathlib import Path from dataclasses import dataclass, field from typing import List, Optional logger = logging.getLogger('energy_fault_detector') -here = os.path.abspath(os.path.dirname(__file__)) +here = Path(__file__).resolve().parent @dataclass class Options: - csv_test_data_path: Optional[str] = None + csv_test_data_path: Optional[str | Path] = None train_test_column_name: Optional[str] = None train_test_mapping: Optional[dict] = None time_column_name: Optional[str] = None @@ -27,7 +27,7 @@ class Options: enable_debug_plots: bool = False -def load_options_from_yaml(file_path: str) -> Options: +def load_options_from_yaml(file_path: str | Path) -> Options: """Load options from a YAML file and return an Options dataclass.""" with open(file_path, 'r') as file: options_dict = yaml.safe_load(file) @@ -76,19 +76,19 @@ def main(): parser.add_argument( 'csv_data_path', - type=str, + type=Path, help='Path to a CSV file containing training data.' ) parser.add_argument( '--options', - type=str, + type=Path, help='Path to a YAML file containing additional options.', default=None, required=False, ) parser.add_argument( '--results_dir', - type=str, + type=Path, help='Path to a directory where results will be saved.', default='results' ) @@ -107,13 +107,13 @@ def main(): logger.info(f"Options YAML: {args.options}") logger.info(f"Results Directory: {args.results_dir}") - os.makedirs(args.results_dir, exist_ok=True) + args.results_dir.mkdir(exist_ok=True) options = Options() # Initialize with default values if args.options: options = load_options_from_yaml(args.options) elif args.c2c_example: - options = load_options_from_yaml(os.path.join(here, 'c2c_options.yaml')) + options = load_options_from_yaml(here / 'c2c_options.yaml') print(options) @@ -136,12 +136,13 @@ def main(): min_anomaly_length=options.min_anomaly_length, save_dir=args.results_dir, ) - logger.info(f'Fault detection completed. Results are saved in {args.results_dir}.') + logger.info(f'Fault detection completed. Results are saved in the directory "{args.results_dir}".') prediction_results.save(args.results_dir) - event_meta_data.to_csv(os.path.join(args.results_dir, 'events.csv'), index=False) + event_meta_data.to_csv(args.results_dir / 'events.csv', index=False) except Exception as e: logger.error(f'An error occurred: {e}') + raise if __name__ == '__main__': diff --git a/energy_fault_detector/quick_fault_detection/configuration.py b/energy_fault_detector/quick_fault_detection/configuration.py index 412199d..cf65608 100644 --- a/energy_fault_detector/quick_fault_detection/configuration.py +++ b/energy_fault_detector/quick_fault_detection/configuration.py @@ -68,10 +68,37 @@ def update_preprocessor_config(config: Config, features_to_exclude: Union[List[s Returns: Config: Updated config object. """ + if features_to_exclude is not None: - config['train']['data_preprocessor']['params']['features_to_exclude'] = features_to_exclude + if config['train']['data_preprocessor'].get('params'): + # old data preprocessing configuration style + config['train']['data_preprocessor']['params']['features_to_exclude'] = features_to_exclude + else: + # new configuration style + steps = config['train']['data_preprocessor'].setdefault('steps', []) + column_selector_found = False + for step in steps: + if step['name'] == 'column_selector': + step['params']['features_to_exclude'] = features_to_exclude + column_selector_found = True + break + if not column_selector_found: + steps.append({'name': 'column_selector', 'params': {'features_to_exclude': features_to_exclude}}) if angles is not None: - config['train']['data_preprocessor']['params']['angles'] = angles + if config['train']['data_preprocessor'].get('params'): + # old data preprocessing configuration style + config['train']['data_preprocessor']['params']['angles'] = angles + else: + # new configuration style + steps = config['train']['data_preprocessor'].setdefault('steps', []) + angle_transformer_found = False + for step in steps: + if step['name'] == 'angle_transformer': + step['params']['angles'] = angles + angle_transformer_found = True + break + if not angle_transformer_found: + steps.append({'name': 'angle_transformer', 'params': {'angles': angles}}) return config diff --git a/energy_fault_detector/quick_fault_detection/optimization.py b/energy_fault_detector/quick_fault_detection/optimization.py index a8de03c..fcadc2f 100644 --- a/energy_fault_detector/quick_fault_detection/optimization.py +++ b/energy_fault_detector/quick_fault_detection/optimization.py @@ -127,6 +127,9 @@ def reconstruction_mse(trial: op.Trial) -> float: deviations = training_dict.val_recon_error score = float(np.mean((np.square(deviations)))) + # help garbage collection + del model + return score study = op.create_study(sampler=op.samplers.TPESampler(), diff --git a/energy_fault_detector/quick_fault_detection/quick_fault_detector.py b/energy_fault_detector/quick_fault_detection/quick_fault_detector.py index f244de5..dfc82d8 100644 --- a/energy_fault_detector/quick_fault_detection/quick_fault_detector.py +++ b/energy_fault_detector/quick_fault_detection/quick_fault_detector.py @@ -1,12 +1,13 @@ """Quick energy fault detection, to try out the EnergyFaultDetector model on a specific dataset.""" import os +from pathlib import Path import logging from typing import List, Optional, Tuple import pandas as pd -from energy_fault_detector._logs import setup_logging +from energy_fault_detector.core._logs import setup_logging from energy_fault_detector.fault_detector import FaultDetector from energy_fault_detector.utils.analysis import create_events from energy_fault_detector.root_cause_analysis.arcana_utils import calculate_mean_arcana_importances @@ -20,14 +21,14 @@ logger = logging.getLogger('energy_fault_detector') -def quick_fault_detector(csv_data_path: str, csv_test_data_path: Optional[str] = None, +def quick_fault_detector(csv_data_path: str | Path, csv_test_data_path: Optional[str | Path] = None, train_test_column_name: Optional[str] = None, train_test_mapping: Optional[dict] = None, time_column_name: Optional[str] = None, status_data_column_name: Optional[str] = None, status_mapping: Optional[dict] = None, status_label_confidence_percentage: Optional[float] = 0.95, features_to_exclude: Optional[List[str]] = None, angle_features: Optional[List[str]] = None, automatic_optimization: bool = True, enable_debug_plots: bool = False, - min_anomaly_length: int = 18, save_dir: Optional[str] = None + min_anomaly_length: int = 18, save_dir: Optional[str | Path] = None ) -> Tuple[FaultDetectionResult, pd.DataFrame]: """Analyzes provided data using an autoencoder based approach for identifying anomalies based on a learned normal behavior. Anomalies are then aggregated to events and further analyzed. diff --git a/energy_fault_detector/root_cause_analysis/arcana.py b/energy_fault_detector/root_cause_analysis/arcana.py index b2a0bf5..65180ee 100644 --- a/energy_fault_detector/root_cause_analysis/arcana.py +++ b/energy_fault_detector/root_cause_analysis/arcana.py @@ -22,9 +22,10 @@ class Arcana: """Anomaly root cause analysis. Tries to find which of the sensors/inputs caused - the reconstruction error of an autoencoder model. + the reconstruction error of an autoencoder model. Implementation details are found in + https://doi.org/10.1016/j.egyai.2021.100065. - This is done by minimizing the loss function: + This method minimizes the loss function: '(1 - alpha) L2(X_corr - autoencoder(X_corr)) + alpha * L1(X_corr - X_obs)' @@ -103,11 +104,12 @@ def find_arcana_bias(self, x: pd.DataFrame, track_losses: bool = False, track_bi loss 2 for each 50th iteration) track_bias: If True bias will be returned as a list arcana biases each 50th iteration) - Returns: - x_bias: pandas DataFrame - tracked_losses: A dataframe containing the combined loss, loss 1 (reconstruction) and - loss 2 (regularization) for each 50th iteration (if track_losses is False this list is empty) - tracked_bias: A List of dataframes representing x_bias + Returns: A tuple with the following three objects + + - x_bias: pandas DataFrame + - tracked_losses: A dataframe containing the combined loss, loss 1 (reconstruction) and + loss 2 (regularization) for each 50th iteration (if track_losses is False this list is empty) + - tracked_bias: A List of dataframes representing x_bias """ conditions = None diff --git a/energy_fault_detector/utils/data_downloads.py b/energy_fault_detector/utils/data_downloads.py index 6abeef1..1742155 100644 --- a/energy_fault_detector/utils/data_downloads.py +++ b/energy_fault_detector/utils/data_downloads.py @@ -2,7 +2,6 @@ from typing import List, Union import os import re -import sys import shutil import logging from pathlib import Path @@ -142,6 +141,30 @@ def safe_extract_zip(zip_path: Path, dest_dir: Path): zf.extractall(dest_dir) +def recursive_safe_extract(zip_path: Path, dest_dir: Path, remove_archives: bool = True): + """ + Recursively extracts ZIP files, including those found inside other ZIPs. + + Args: + zip_path: Path to the .zip archive. + dest_dir: Directory to extract into. + remove_archives: Whether to delete the .zip file after successful extraction. + """ + logger.info(f"Extracting {zip_path.name} to {dest_dir}") + safe_extract_zip(zip_path, dest_dir) + + if remove_archives: + try: + zip_path.unlink() + except OSError as e: + logger.warning(f"Could not remove archive {zip_path}: {e}") + + # After extraction, check if any new .zip files were created in the dest_dir + for item in list(dest_dir.rglob("*.zip")): + recursive_safe_extract(item, item.parent, remove_archives=remove_archives) + + + def prepare_output_dir(out_dir: Path, overwrite: bool) -> None: """Ensure the output directory is ready. @@ -172,25 +195,40 @@ def prepare_output_dir(out_dir: Path, overwrite: bool) -> None: def download_zenodo_data(identifier: str = "10.5281/zenodo.15846963", dest: Path = "./downloads", - overwrite: bool = False) -> Union[List[Path], Path]: + remove_zip: bool = True, overwrite: bool = False, flatten_file_structure: bool = True, + expected_file_types: Union[List[str], str] = "*.csv") -> Path: """ Download a Zenodo record via API and unzip any .zip files. + Downloads all files associated with a given Zenodo record (by ID, DOI, or URL), + saves them to a local directory, and optionally flattens nested directories + that result from extracting ZIP archives. + Args: - identifier (str): Zenodo record ID, DOI (e.g., 10.5281/zenodo.15846963), or record URL - dest (Path): Output directory (default: downloads) + identifier (str): Zenodo record ID, DOI (e.g., 10.5281/zenodo.15846963), or record URL. + Defaults to the CARE2Compare dataset. + dest (Path): Local output directory to save downloaded files. (default: downloads) + remove_zip (bool): If True, ZIP archives will be removed after extraction. overwrite (bool): If True and dest already exists, contents of dest will be overwritten. + Default is False. + flatten_file_structure (bool): If True and unzipping results in a single top-level folder + with no conflicting root-level files matching `expected_file_types`, + moves its contents up one level. Default is True. + expected_file_types (Union[List[str], str]): Glob pattern(s) used to detect existing relevant files + at the root. If any match, flattening is skipped. + Can be a string like '*.csv' or list like ['*.csv', '*.json']. Default is '*.csv'. Returns: - Union[List[Path], Path]: List of paths the extracted content of all downloaded zip files. If there is only one - downloaded zip file only one path is returned + Path: The absolute path to the directory containing the downloaded and unzipped data. """ + if isinstance(expected_file_types, str): + expected_file_types = [expected_file_types] session = requests.Session() try: record_id = parse_record_id(identifier) except ValueError as e: logger.error(e) - sys.exit(1) + raise out_dir = Path(dest) @@ -200,7 +238,7 @@ def download_zenodo_data(identifier: str = "10.5281/zenodo.15846963", dest: Path prepare_output_dir(out_dir, overwrite) except Exception as e: logger.error(f"Failed to prepare output directory: {e}") - sys.exit(1) + raise logger.info(f"Fetching record {record_id} metadata...") record = fetch_record(session, record_id) @@ -209,7 +247,7 @@ def download_zenodo_data(identifier: str = "10.5281/zenodo.15846963", dest: Path files = list_files(session, record) except RuntimeError as e: logger.error(e) - sys.exit(1) + raise downloaded = [] for f in files: @@ -228,33 +266,23 @@ def download_zenodo_data(identifier: str = "10.5281/zenodo.15846963", dest: Path # Unzip any downloaded .zip files for p in downloaded: if p.suffix.lower() == ".zip": - extract_dir = p.with_suffix("") # folder named after the zip - logger.info(f"Unzipping: {p.name} -> {extract_dir}") + extract_target = out_dir # Extract directly into dest + logger.info(f"Unzipping: {p.name} -> {extract_target}") try: - safe_extract_zip(p, extract_dir) + recursive_safe_extract(p, extract_target, remove_archives=remove_zip) except Exception as e: logger.error(f"Unzipping failed for {p.name}: {e}") - else: - try: - p.unlink() - logger.info(f"Removed archive: {p.name}") - except OSError as e: - logger.warning(f"Could not remove {p}: {e}") - - logger.info(f"Validating file structure.") - # Check resulting file structure and remove duplicate directory names if they exist due to unzipping. - root_paths = [] - for file_or_dir in os.listdir(out_dir): - root_path = out_dir / file_or_dir - if os.path.isdir(root_path): - root_paths.append(root_path) - if file_or_dir in os.listdir(root_path): - duplicate_dir_name = root_path / file_or_dir - logger.info(f"Removing redundant directory: {duplicate_dir_name}") - move_list = os.listdir(duplicate_dir_name) - for content in move_list: - shutil.move(src=duplicate_dir_name / content, - dst=root_path / content) - os.rmdir(duplicate_dir_name) - logger.info(f"File structure validated.") - return root_paths if len(root_paths) > 1 else root_paths[0] + + if flatten_file_structure: + logger.info(f"Flattening file structure.") + # Standardize structure: If unzipping created a single subfolder, move its contents up + # This often happens with Zenodo zips. + subdirs = [d for d in out_dir.iterdir() if d.is_dir()] + if len(subdirs) == 1 and not any(next(out_dir.glob(pattern), None) for pattern in expected_file_types): + redundant_dir = subdirs[0] + logger.info(f"Flattening directory structure from {redundant_dir}") + for item in redundant_dir.iterdir(): + shutil.move(str(item), str(out_dir / item.name)) + redundant_dir.rmdir() + + return out_dir diff --git a/energy_fault_detector/utils/visualisation.py b/energy_fault_detector/utils/visualisation.py index 7a0c337..4eda52c 100644 --- a/energy_fault_detector/utils/visualisation.py +++ b/energy_fault_detector/utils/visualisation.py @@ -11,7 +11,8 @@ from energy_fault_detector.core import Autoencoder from energy_fault_detector.fault_detector import FaultDetector -from energy_fault_detector.utils.analysis import calculate_criticality + +MAX_PLOTS = 20 def plot_learning_curve(model: Union[Autoencoder, FaultDetector], ax: plt.Axes = None, label: str = '', @@ -71,7 +72,7 @@ def plot_reconstruction(data: pd.DataFrame, reconstruction: pd.DataFrame, featur missing = set(to_plot) - set(data.columns) raise ValueError(f'The columns {missing} are not present in the dataset.') - if len(to_plot) > 30: # You can adjust this threshold + if len(to_plot) > MAX_PLOTS: warnings.warn(f"You are attempting to plot a large number of features ({len(to_plot)}). " "This may result in a cluttered figure. Consider selecting fewer features to plot.") @@ -93,6 +94,67 @@ def plot_reconstruction(data: pd.DataFrame, reconstruction: pd.DataFrame, featur return fig, ax +def plot_reconstruction_with_model(model: FaultDetector, data: pd.DataFrame, + features_to_plot: Optional[List[str]] = None, + height_multiplier: float = 1.5, + original_scale: bool = True) -> Tuple[plt.Figure, plt.Axes]: + """Plots the data and its reconstruction using the provided model. Similar to plot_reconstruction, but uses the + 'model.predict' method to get the reconstruction. Counter values are plottet as diffs or rates with their + reconstruction. + + Args: + model (FaultDetector): Fitted model with data_preprocessor and autoencoder. + data (pd.DataFrame): Raw input data. + features_to_plot (Optional[List[str]], optional): Columns to plot. If None, uses reconstruction columns. + height_multiplier (float, optional): Vertical scaling for the figure. Defaults to 1.5. + original_scale (bool, optional): If True, y-limits are based on the observed plot-series + (min-std, max+std). Defaults to True. + + Returns: + Tuple[plt.Figure, plt.Axes]: The figure and axes. + """ + + # Get model predictions + predictions = model.predict(sensor_data=data) + reconstruction = predictions.reconstruction + # model data preprocessor + dp = model.data_preprocessor + + # Discover counter mappings (original -> derived) from CounterDiffTransformer steps + from energy_fault_detector.data_preprocessing.counter_diff_transformer import CounterDiffTransformer + counter_map = {} # original counter -> derived column (e.g., energy_total_kwh -> energy_total_kwh_diff) + for name, est in dp.named_steps.items(): + if isinstance(est, CounterDiffTransformer): + # Need fitted attributes + try: + counters = getattr(est, "counters_", []) + suffix = getattr(est, "output_suffix_", "_diff") + except Exception: + counters = [] + suffix = "_diff" + for c in counters: + counter_map[c] = f"{c}{suffix}" + + # Determine features to plot + to_plot = list(reconstruction.columns) if features_to_plot is None else features_to_plot + + # If any counter are in features to plot, transform input data, so we can plot the diffs with their reconstructions + if any(col in counter_map.keys() for col in to_plot): + # replace counters with their _diff/_rate name + to_plot = [col if col not in counter_map.keys() else counter_map[col] for col in to_plot] + dataset_to_plot = dp.inverse_transform(dp.transform(data.copy()))[to_plot] + else: + dataset_to_plot = data[to_plot].copy() + + return plot_reconstruction( + dataset_to_plot, + reconstruction, + features_to_plot=to_plot, + height_multiplier=height_multiplier, + original_scale=original_scale + ) + + def plot_score_with_threshold(model: FaultDetector, data: pd.DataFrame, normal_index: pd.Series = None, ax: plt.Axes = None, figsize: Tuple[float, float] = (8, 3), show_predicted_anomaly: bool = False, show_threshold: bool = True, @@ -157,8 +219,7 @@ def plot_score_with_threshold(model: FaultDetector, data: pd.DataFrame, normal_i ax.plot(threshold, linestyle='-', linewidth=.7, label='threshold', c=threshold_color) if show_criticality: - crit = calculate_criticality(predictions.predicted_anomalies["anomaly"], normal_idx=normal_index, - max_criticality=max_criticality) + crit = predictions.criticality(normal_idx=normal_index, max_criticality=max_criticality) ax2 = ax.twinx() ax2.plot(crit, label='criticality counter', color=criticality_color) ax2.legend(loc='upper right', markerscale=3) @@ -166,9 +227,13 @@ def plot_score_with_threshold(model: FaultDetector, data: pd.DataFrame, normal_i ax.set_ylabel('anomaly score') - legend = ax.legend(loc='upper left', markerscale=3) - for h in legend.legend_handles: - h.set_alpha(1) + # Get handles and labels from the current axes + handles, labels = ax.get_legend_handles_labels() + if labels: + # Only create the legend if there are labels found + legend = ax.legend(loc='upper left', markerscale=3) + for h in legend.legend_handles: + h.set_alpha(1) return fig, ax diff --git a/notebooks/CARE to Compare.ipynb b/notebooks/CARE to Compare/CARE to Compare.ipynb similarity index 92% rename from notebooks/CARE to Compare.ipynb rename to notebooks/CARE to Compare/CARE to Compare.ipynb index 247c101..0b86a6e 100644 --- a/notebooks/CARE to Compare.ipynb +++ b/notebooks/CARE to Compare/CARE to Compare.ipynb @@ -2,6 +2,13 @@ "cells": [ { "cell_type": "markdown", + "id": "fe8e2b4b8752a26d", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "# CARE Score and Care2CompareDataset usage\n", "\n", @@ -12,14 +19,11 @@ "2. Using the CAREScore to evaluate a model on the dataset.\n", "3. Recreating the results of the CARE paper.\n", "4. Using Care2CompareDataset and CARE-Score for other datasets." - ], - "metadata": { - "collapsed": false - }, - "id": "fe8e2b4b8752a26d" + ] }, { "cell_type": "code", + "execution_count": null, "id": "initial_id", "metadata": { "collapsed": true, @@ -27,6 +31,7 @@ "outputs_hidden": true } }, + "outputs": [], "source": [ "import os\n", "from pathlib import Path\n", @@ -36,51 +41,49 @@ "from energy_fault_detector.fault_detector import FaultDetector\n", "from energy_fault_detector.config import Config\n", "from energy_fault_detector.evaluation import CAREScore, Care2CompareDataset" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "id": "75ac0a42f7c2f795", "metadata": {}, + "outputs": [], "source": [ "data_dir = Path('..') / '..' / 'Care_To_Compare_v6'" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "id": "70f2af66920ba09b", "metadata": {}, + "outputs": [], "source": [ "c2c = Care2CompareDataset(path=data_dir, download_dataset=False) # If you have not downloaded the dataset yet, set download_dataset to True" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "id": "8a1422f3e62850ab", "metadata": {}, + "outputs": [], "source": [ "c2c.event_info_all" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "id": "48309c102d52aeb0", "metadata": {}, + "outputs": [], "source": [ "# select data for a specific event\n", "x, y = c2c.load_event_dataset(0, statistics=['average', 'std_dev'])\n", "x.head()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -91,8 +94,11 @@ ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "e9087f75625207d2", + "metadata": {}, + "outputs": [], "source": [ "c2c = Care2CompareDataset(data_dir)\n", "index_column = 'id' # us time_stamp as index column if you are using the TimestampTransformer\n", @@ -174,14 +180,14 @@ "\n", " # print final score:\n", " print('Final score: ', care_score.get_final_score())" - ], - "id": "e9087f75625207d2", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "a41c52ce61dd7e06", + "metadata": {}, + "outputs": [], "source": [ "# combine results and get final score over all events / wind farms\n", "all_evaluations = pd.concat([pd.read_csv(f'results_{wf}{suffix}.csv') for wf in ['A', 'B', 'C']])\n", @@ -203,10 +209,7 @@ "\n", "print('overall')\n", "care_score.get_final_score()" - ], - "id": "a41c52ce61dd7e06", - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -221,8 +224,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "ac23c5d8fe923863", "metadata": {}, + "outputs": [], "source": [ "# model config\n", "wf = 'A'\n", @@ -263,39 +268,49 @@ " predicted_anomalies=prediction.predicted_anomalies,\n", " ignore_normal_index=True,\n", " )\n" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "id": "240be4ff7c0b1325", "metadata": {}, + "outputs": [], "source": [ "care_score.get_final_score()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", + "id": "5b4cf2cd5b2f9516", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "# Reproducing results from the Paper\n", "To reproduce the results from (https://doi.org/10.3390/data9120138), an additional filter is needed (though only for wind farm C):\n", "- determine cut-in and cut-off wind speeds by power curve analysis\n", "- Remove potentially anomalous data from the training data:\n", " - Remove rows where the wind speed is outside the normal operation range (below cut-in or above cut-off)\n", - " - Remove rows where the power is zero or near zero (e.g. $P < 0.01$)." - ], - "metadata": { - "collapsed": false - }, - "id": "5b4cf2cd5b2f9516" + " - Remove rows where the power is zero or near zero (e.g. $P < 0.01$).\n", + "\n", + "Note: The trained models may not reproduce the exact results reported in the paper due to random initialization, hardware differences, and random seeds. In practice, it is advisable to train each model 5–10 times and select the best-performing run." + ] }, { "cell_type": "markdown", + "id": "8feb0d8f0e917072", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ - "# CARE Score and Care2CompareDataset usage on other datasets\n", + "# CARE Score usage on other datasets\n", "\n", "To use the CARE-Score with other datasets you need the following data:\n", "- define events containing anomalous data (the period before an actual fault)\n", @@ -309,11 +324,7 @@ "- Calculate the CARE score `CAREScore.get_final_score`\n", "\n", "For each of these events, you need to be able to train a proper model (for example one large model or a model for each event). For the CARE2Compare dataset we assumed 1 year of training data with >=70% normal operation is enough to create a normal behavior model.\n" - ], - "metadata": { - "collapsed": false - }, - "id": "8feb0d8f0e917072" + ] } ], "metadata": { @@ -332,7 +343,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.12.0" } }, "nbformat": 4, diff --git a/notebooks/c2c_configs/windfarm_A.yaml b/notebooks/CARE to Compare/c2c_configs/windfarm_A.yaml similarity index 75% rename from notebooks/c2c_configs/windfarm_A.yaml rename to notebooks/CARE to Compare/c2c_configs/windfarm_A.yaml index da7133a..5ef3264 100644 --- a/notebooks/c2c_configs/windfarm_A.yaml +++ b/notebooks/CARE to Compare/c2c_configs/windfarm_A.yaml @@ -4,14 +4,15 @@ train: upper_percentile: 0.999 data_preprocessor: - params: - include_column_selector: true - include_low_unique_value_filter: true - include_duplicate_value_to_nan: false - max_col_zero_frac: 0.99 - max_nan_frac_per_col: 0.05 - min_unique_value_count: 10 - scale: minmax + steps: + - name: column_selector + params: + max_nan_frac_per_col: 0.05 + - name: low_unique_value_filter + params: + min_unique_value_count: 10 + max_col_zero_frac: 0.99 + - name: minmax_scaler data_splitter: shuffle: true diff --git a/notebooks/c2c_configs/windfarm_B.yaml b/notebooks/CARE to Compare/c2c_configs/windfarm_B.yaml similarity index 74% rename from notebooks/c2c_configs/windfarm_B.yaml rename to notebooks/CARE to Compare/c2c_configs/windfarm_B.yaml index 7b55de8..68320b9 100644 --- a/notebooks/c2c_configs/windfarm_B.yaml +++ b/notebooks/CARE to Compare/c2c_configs/windfarm_B.yaml @@ -4,14 +4,15 @@ train: upper_percentile: 0.999 data_preprocessor: - params: - include_column_selector: true - include_low_unique_value_filter: true - include_duplicate_value_to_nan: false - max_col_zero_frac: 0.8 - max_nan_frac_per_col: 0.05 - min_unique_value_count: 10 - scale: minmax + steps: + - name: column_selector + params: + max_nan_frac_per_col: 0.05 + - name: low_unique_value_filter + params: + min_unique_value_count: 10 + max_col_zero_frac: 0.8 + - name: minmax_scaler data_splitter: shuffle: true diff --git a/notebooks/c2c_configs/windfarm_C.yaml b/notebooks/CARE to Compare/c2c_configs/windfarm_C.yaml similarity index 69% rename from notebooks/c2c_configs/windfarm_C.yaml rename to notebooks/CARE to Compare/c2c_configs/windfarm_C.yaml index c618cc3..3f7b08a 100644 --- a/notebooks/c2c_configs/windfarm_C.yaml +++ b/notebooks/CARE to Compare/c2c_configs/windfarm_C.yaml @@ -4,14 +4,15 @@ train: upper_percentile: 0.999 data_preprocessor: - params: - include_column_selector: true - include_low_unique_value_filter: true - include_duplicate_value_to_nan: false - max_col_zero_frac: 0.99 - max_nan_frac_per_col: 0.05 - min_unique_value_count: 10 - scale: minmax + steps: + - name: column_selector + params: + max_nan_frac_per_col: 0.05 + - name: low_unique_value_filter + params: + min_unique_value_count: 10 + max_col_zero_frac: 0.99 + - name: minmax_scaler data_splitter: shuffle: true diff --git a/notebooks/Example - Hyperparameter Optimization.ipynb b/notebooks/Example - Hyperparameter Optimization.ipynb index 37abecf..344db94 100644 --- a/notebooks/Example - Hyperparameter Optimization.ipynb +++ b/notebooks/Example - Hyperparameter Optimization.ipynb @@ -10,119 +10,58 @@ "1. Optimizing the Autoencoder reconstruction using the MSE\n", "2. Optimizing the FaultDetector classification performance using the Fbeta score\n", "3. Optimizing the FaultDetector classification performance using the CARE-score\n", - "The optimization is done using the [CARE to Compare dataset](https://doi.org/10.5281/zenodo.14958989)\n", "\n", - "For this example you need to install Optuna, which is not contained in the standard requirements of the framework\n", - "Optuna [docs](https://optuna.readthedocs.io/en/stable/index.html) and [tutorials](https://optuna.readthedocs.io/en/stable/tutorial/index.html)\n", - "\n", - "-> Install additional requirements for this example using 'pip notebooks/example_requirements.txt'" + "The optimization is done using the [CARE to Compare dataset](https://doi.org/10.5281/zenodo.14958989)" ], - "id": "552412b97335ea1c" + "id": "acc177b6ece47b21" }, { + "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ - "from typing import List\n", + "from copy import deepcopy\n", "\n", "import optuna as op\n", - "import pandas as pd\n", "import numpy as np\n", "from sklearn.metrics import fbeta_score\n", "\n", "from energy_fault_detector import FaultDetector, Config\n", "from energy_fault_detector.evaluation import CAREScore, Care2CompareDataset" ], - "metadata": { - "collapsed": false - }, - "id": "217e454f48a9879b", - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "source": [ - "data_path = './Care_To_Compare'" - ], - "metadata": { - "collapsed": false - }, - "id": "1d2e20520349e34e", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "def update_config(config: Config, feature_descriptions: pd.DataFrame) -> None:\n", - " \"\"\"Update config based on provided feature descriptions.\"\"\"\n", - "\n", - " def get_columns(feature_description_selection: pd.DataFrame) -> List[str]:\n", - " col_suffix = {\n", - " 'average': 'avg',\n", - " 'minimum': 'min',\n", - " 'maximum': 'max',\n", - " 'std_dev': 'std'\n", - " }\n", - " columns = []\n", - " for _, row in feature_description_selection.iterrows():\n", - " if row.statistics_type == 'average':\n", - " # in this case the column can be either sensor_i or sensor_i_avg, so we add both\n", - " columns.append(row.sensor_name)\n", - " for stat in row.statistics_type.split(','):\n", - " columns.append(f'{row.sensor_name}_{col_suffix[stat]}')\n", - " return columns\n", - "\n", - " angles = feature_descriptions.loc[feature_descriptions['is_angle']]\n", - " to_exclude = feature_descriptions.loc[feature_descriptions['is_counter']]\n", - "\n", - " angle_columns = get_columns(angles)\n", - " to_exclude_columns = get_columns(to_exclude)\n", - " \n", - " config['train']['data_preprocessor']['params']['angles'] = (\n", - " config['train']['data_preprocessor']['params'].get('angles', []) + angle_columns\n", - " )\n", - " config['train']['data_preprocessor']['params']['features_to_exclude'] = (\n", - " config['train']['data_preprocessor']['params'].get('features_to_exclude', []) + to_exclude_columns\n", - " )\n", - " \n", - " config.update_config(config.config_dict)\n" - ], - "id": "723566d33fa10db6", - "outputs": [], - "execution_count": null + "id": "f498cdd89c2cf406" }, { "metadata": {}, "cell_type": "code", - "source": [ - "c2c = Care2CompareDataset(data_path)" - ], - "id": "ec8c21f059bba3c8", "outputs": [], - "execution_count": null + "execution_count": null, + "source": "data_path = './Care_To_Compare'", + "id": "641b5084847234b8" }, { "metadata": {}, "cell_type": "markdown", - "source": [ - "### Optimize autoencoder reconstruction" - ], - "id": "99fcaa7054047666" + "source": "## Optimize autoencoder reconstruction", + "id": "d5ac0225fa5e566b" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ - "model_config = Config('c2c_configs/windfarm_C.yaml') # starting point\n", - "\n", - "# our test set\n", + "# Our test set (a specific event from the CARE2Compare dataset)\n", "c2c = Care2CompareDataset(data_path)\n", "event_id = 47\n", - "train_data, normal_index, _, _ = c2c.get_formatted_event_dataset(event_id=event_id, index_column='time_stamp')\n", + "train_data, normal_index, _, _ = c2c.load_and_format_event_dataset(event_id=event_id, index_column='time_stamp')\n", "\n", - "# speed up for testing\n", + "# Model configuration starting point\n", + "model_config = Config('c2c_configs/windfarm_C.yaml')\n", + "c2c.update_c2c_config(model_config, 'C')\n", + "\n", + "# speed up for testing (select a small part of the dataset)\n", "N = 10000\n", "normal_index = normal_index.iloc[:N]\n", "train_data = train_data.iloc[:N]\n", @@ -140,36 +79,23 @@ " Returns:\n", " MSE of the reconstruction.\n", " \"\"\"\n", + " # Use a fresh config dict per trial\n", + " cfg = deepcopy(model_config.config_dict)\n", "\n", - " autoencoder_params = model_config.config_dict['train']['autoencoder']['params']\n", + " autoencoder_params = cfg['train']['autoencoder']['params']\n", "\n", " # sample new parameters\n", - " autoencoder_params['batch_size'] = int(trial.suggest_categorical(\n", - " name='batch_size', choices=[32, 64, 128]\n", - " ))\n", - " autoencoder_params['learning_rate'] = trial.suggest_float(\n", - " name='learning_rate', low=1e-5, high=0.01, log=True\n", - " )\n", - " autoencoder_params['decay_rate'] = trial.suggest_float(\n", - " name='decay_rate', low=0.8, high=0.99\n", - " )\n", + " autoencoder_params['batch_size'] = int(trial.suggest_categorical(name='batch_size', choices=[32, 64, 128]))\n", + " autoencoder_params['learning_rate'] = trial.suggest_float(name='learning_rate', low=1e-5, high=0.01, log=True)\n", + " autoencoder_params['decay_rate'] = trial.suggest_float(name='decay_rate', low=0.8, high=0.99)\n", "\n", " # architecture\n", - " autoencoder_params['layers'][0] = trial.suggest_int(\n", - " name='layers_0', low=100, high=400\n", - " )\n", - " autoencoder_params['layers'][1] = trial.suggest_int(\n", - " name='layers_1', low=50, high=100\n", - " )\n", - " autoencoder_params['code_size'] = trial.suggest_int(\n", - " name='code_size', low=10, high=30\n", - " )\n", - "\n", - " # update the configuration\n", - " model_config.update_config(model_config.config_dict)\n", + " autoencoder_params['layers'][0] = trial.suggest_int(name='layers_0', low=100, high=400)\n", + " autoencoder_params['layers'][1] = trial.suggest_int(name='layers_1', low=50, high=100)\n", + " autoencoder_params['code_size'] = trial.suggest_int(name='code_size', low=10, high=30)\n", "\n", " # create a new model using our new configuration and train the model\n", - " model = FaultDetector(model_config)\n", + " model = FaultDetector(Config(config_dict=cfg))\n", " # For autoencoder optimization, we do not need to fit a threshold\n", " training_result = model.fit(train_data, normal_index=normal_index, fit_autoencoder_only=True, save_model=False)\n", "\n", @@ -179,81 +105,69 @@ "\n", " return score" ], - "id": "eadbdf08b64a43e4", - "outputs": [], - "execution_count": null + "id": "6cc9b7bee0de0a25" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ - "study = op.create_study(sampler=op.samplers.TPESampler(),\n", - " study_name='autoencoder_optimization',\n", - " direction='minimize')\n", + "study = op.create_study(sampler=op.samplers.TPESampler(), study_name='autoencoder_optimization', direction='minimize')\n", "\n", "# if we want to ensure that the first trial is done with the hyperparameters of the configuration, we need to enqueue a trial:\n", "autoencoder_params = model_config.config_dict['train']['autoencoder']['params']\n", "study.enqueue_trial(params={\n", " 'batch_size': autoencoder_params['batch_size'],\n", " 'learning_rate': autoencoder_params['learning_rate'],\n", - " 'decay_rate': autoencoder_params['decay_rate'],\n", " 'layers_0': autoencoder_params['layers'][0],\n", " 'layers_1': autoencoder_params['layers'][1],\n", " 'code_size': autoencoder_params['code_size'],\n", - "})" - ], - "id": "ff13f4aeb3d8b6d0", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ + "})\n", + "\n", + "# Run optimization for 5 trials\n", "study.optimize(reconstruction_mse, n_trials=5)" ], - "id": "edfa14b97d00a0b9", - "outputs": [], - "execution_count": null + "id": "1036f11c1b97e12" }, { "metadata": {}, "cell_type": "code", - "source": [ - "study.best_params" - ], - "id": "91747046042e809a", "outputs": [], - "execution_count": null + "execution_count": null, + "source": "study.best_params", + "id": "7d452e30dd1a1e0e" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "# analyze results\n", "study.trials_dataframe()" ], - "id": "4cf3e2b09291ba57", - "outputs": [], - "execution_count": null + "id": "5f061454dc9f7980" }, { "metadata": {}, "cell_type": "markdown", - "source": "# Optimize fault detection model", - "id": "cb3c07b2be2cb5ed" + "source": "## Optimize fault detection model - F-beta score", + "id": "80664d61f648132f" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ + "# Our test set (a specific event from the CARE2Compare dataset)\n", "c2c = Care2CompareDataset(data_path)\n", - "\n", "event_id = 47\n", - "event_info = c2c.event_info_all[c2c.event_info_all['event_id'] == event_id].iloc[0]\n", - "\n", - "train_data, normal_index, test_data, test_normal_index = c2c.get_formatted_event_dataset(event_id=event_id, index_column='time_stamp')\n", + "train_data, normal_index, test_data, test_normal_index = c2c.load_and_format_event_dataset(event_id=event_id, index_column='time_stamp')\n", "\n", + "# Create a ground truth for this event\n", + "event_info = c2c.event_info_all[c2c.event_info_all['event_id'] == event_id].iloc[0]\n", "ground_truth = CAREScore.create_ground_truth(\n", " event_label=event_info['event_label'],\n", " event_start=event_info['event_start'],\n", @@ -261,21 +175,42 @@ " normal_index=test_normal_index\n", ")" ], - "id": "fa98c752d06b0e35", - "outputs": [], - "execution_count": null + "id": "3d4b96de7af51a2a" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ - "model_config = Config('c2c_configs/windfarm_C.yaml') # starting point\n", + "# Model configuration starting point\n", + "model_config = Config('c2c_configs/windfarm_C.yaml')\n", + "c2c.update_c2c_config(model_config, 'C')\n", "\n", - "# speed up for testing\n", + "# speed up for testing (select a small part of the dataset)\n", "N = 10000\n", "normal_index = normal_index.iloc[:N]\n", "train_data = train_data.iloc[:N]\n", "\n", + "# helper function to (re)set the scaling step of the DataPreprocessor\n", + "def set_scaler_step(cfg: dict, choice: str) -> dict:\n", + " \"\"\"Update cfg to use the chosen scaler.\"\"\"\n", + " dp = cfg['train'].setdefault('data_preprocessor', {})\n", + " steps = dp.get('steps')\n", + "\n", + " # Remove any existing scaler step(s)\n", + " scaler_names = {'standard_scaler', 'minmax_scaler'}\n", + " steps = [s for s in steps if s.get('name') not in scaler_names]\n", + " # Add the chosen scaler step\n", + " if choice == 'minmax':\n", + " steps.append({'name': 'minmax_scaler'})\n", + " else:\n", + " # 'standardize'\n", + " steps.append({'name': 'standard_scaler'})\n", + "\n", + " dp['steps'] = steps\n", + " return cfg\n", + "\n", "\n", "def f_score(trial: op.Trial) -> float:\n", " \"\"\"Returns the F-score of the model (only useful for datasets with anomalies).\n", @@ -286,69 +221,50 @@ " Returns:\n", " Score of the FaultDetector model \n", " \"\"\"\n", + " # Use a fresh config dict per trial\n", + " cfg = deepcopy(model_config.config_dict)\n", "\n", - " dataprep_params = model_config.config_dict['train']['data_preprocessor']['params']\n", - " autoencoder_params = model_config.config_dict['train']['autoencoder']['params']\n", - "\n", - " dataprep_params['scale'] = trial.suggest_categorical(\n", - " name='scale', choices=['minmax', 'standardize']\n", - " )\n", + " # Scale choice (new steps mode or legacy fallback)\n", + " scale_choice = trial.suggest_categorical('scale', ['minmax', 'standardize'])\n", + " cfg = set_scaler_step(cfg, scale_choice)\n", "\n", - " autoencoder_params['batch_size'] = int(trial.suggest_categorical(\n", - " name='batch_size', choices=[32, 64, 128]\n", - " ))\n", - " autoencoder_params['learning_rate'] = trial.suggest_float(\n", - " name='learning_rate', low=1e-5, high=0.01, log=True\n", - " )\n", - " autoencoder_params['decay_rate'] = trial.suggest_float(\n", - " name='decay_rate', low=0.8, high=0.99\n", - " )\n", + " # Autoencoder params\n", + " autoencoder_params = cfg['train']['autoencoder']['params']\n", + " autoencoder_params['batch_size'] = int(trial.suggest_categorical(name='batch_size', choices=[32, 64, 128]))\n", + " autoencoder_params['learning_rate'] = trial.suggest_float(name='learning_rate', low=1e-5, high=0.01, log=True)\n", + " autoencoder_params['decay_rate'] = trial.suggest_float(name='decay_rate', low=0.8, high=0.99)\n", "\n", " # architecture\n", - " autoencoder_params['layers'][0] = trial.suggest_int(\n", - " name='layers_0', low=100, high=400\n", - " )\n", - " autoencoder_params['layers'][1] = trial.suggest_int(\n", - " name='layers_1', low=50, high=100\n", - " )\n", - " autoencoder_params['code_size'] = trial.suggest_int(\n", - " name='code_size', low=10, high=30\n", - " )\n", - "\n", - " # update the configuration\n", - " model_config.update_config(model_config.config_dict)\n", + " autoencoder_params['layers'][0] = trial.suggest_int(name='layers_0', low=100, high=400)\n", + " autoencoder_params['layers'][1] = trial.suggest_int(name='layers_1', low=50, high=100)\n", + " autoencoder_params['code_size'] = trial.suggest_int(name='code_size', low=10, high=30)\n", "\n", " # create a new model using our new configuration and train the model\n", - " model = FaultDetector(model_config)\n", + " model = FaultDetector(Config(config_dict=cfg))\n", " _ = model.fit(train_data, normal_index=normal_index, save_models=False)\n", " predictions = model.predict(test_data)\n", "\n", - " score = fbeta_score(\n", + " return fbeta_score(\n", " y_true=ground_truth.sort_index(),\n", " y_pred=predictions.predicted_anomalies.sort_index(),\n", " beta=0.5\n", - " )\n", - "\n", - " return score" + " )" ], - "id": "1651af56e86b8c17", - "outputs": [], - "execution_count": null + "id": "2d93c80b0090b855" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ - "study = op.create_study(sampler=op.samplers.TPESampler(),\n", - " study_name='ad_optimization',\n", - " direction='maximize')\n", + "study = op.create_study(sampler=op.samplers.TPESampler(), study_name='ad_optimization', direction='maximize')\n", "\n", "# if we want to ensure that the first trial is done with the hyperparameters of the configuration, we need to enqueue a trial:\n", "autoencoder_params = model_config.config_dict['train']['autoencoder']['params']\n", "study.enqueue_trial(params={\n", " 'batch_size': autoencoder_params['batch_size'],\n", " 'learning_rate': autoencoder_params['learning_rate'],\n", - " 'decay_rate': autoencoder_params['decay_rate'],\n", " 'layers_0': autoencoder_params['layers'][0],\n", " 'layers_1': autoencoder_params['layers'][1],\n", " 'code_size': autoencoder_params['code_size'],\n", @@ -356,74 +272,64 @@ "\n", "study.optimize(f_score, n_trials=5)" ], - "id": "713d5956170a993e", - "outputs": [], - "execution_count": null + "id": "d8a1cd60b4664efd" }, { "metadata": {}, "cell_type": "code", - "source": [ - "study.trials_dataframe()" - ], - "id": "7b1b25ddfb3e5b2d", "outputs": [], - "execution_count": null + "execution_count": null, + "source": "study.trials_dataframe()", + "id": "49d60813c32812ed" }, { "metadata": {}, "cell_type": "markdown", "source": [ - "### Optimize CARE score\n", - "Optimize the CARE Score. Note that this is extremely slow, as we train a model for each subdataset." + "## Optimize fault detection model - CARE score\n", + "Optimize the CARE Score. Note that this takes a while, as we train a model for each subdataset." ], - "id": "bbdfaaaf03341cbc" + "id": "bb8fc58e17ba1b64" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ + "# Our test set - Wind Farm B from the CARE2Compare dataset\n", + "c2c = Care2CompareDataset(data_path)\n", "wind_farm = 'B'\n", + "\n", + "# Model configuration starting point\n", "model_config = Config('c2c_configs/windfarm_B.yaml')\n", + "c2c.update_c2c_config(model_config, 'B')\n", "\n", "# speed up for testing\n", - "N = 100\n", + "N = 10000\n", + "max_datasets = 15\n", "\n", "def care_objective(trial: op.Trial) -> float:\n", - " \"\"\"Returns the F-score of the model (only useful for datasets with anomalies).\n", - "\n", - " Args:\n", - " trial: optuna Trial object\n", + " \"\"\"Returns the CARE score of the FaultDetector model.\"\"\"\n", "\n", - " Returns:\n", - " Score of the FaultDetector model.\n", - " \"\"\"\n", + " # Use a fresh config dict per trial\n", + " cfg = deepcopy(model_config.config_dict)\n", "\n", - " autoencoder_params = model_config.config_dict['train']['autoencoder']['params']\n", - " threshold_params = model_config.config_dict['train']['threshold_selector']['params']\n", + " autoencoder_params = cfg['train']['autoencoder']['params']\n", + " threshold_params = cfg['train']['threshold_selector']['params']\n", "\n", - " autoencoder_params['batch_size'] = int(trial.suggest_categorical(\n", - " name='batch_size', choices=[32, 64, 128]\n", - " ))\n", - " autoencoder_params['learning_rate'] = trial.suggest_float(\n", - " name='learning_rate', low=1e-5, high=0.01, log=True\n", - " )\n", + " autoencoder_params['batch_size'] = int(trial.suggest_categorical(name='batch_size', choices=[32, 64, 128]))\n", + " autoencoder_params['learning_rate'] = trial.suggest_float(name='learning_rate', low=1e-5, high=0.01, log=True)\n", "\n", " # architecture\n", - " autoencoder_params['layers'][0] = trial.suggest_int(\n", - " name='layers_0', low=20, high=100\n", - " )\n", - " autoencoder_params['code_size'] = trial.suggest_int(\n", - " name='code_size', low=5, high=20\n", - " )\n", + " autoencoder_params['layers'][0] = trial.suggest_int(name='layers_0', low=20, high=100)\n", + " autoencoder_params['code_size'] = trial.suggest_int(name='code_size', low=5, high=20)\n", "\n", " # threshold\n", " threshold_params['gamma'] = trial.suggest_float(name='gamma', low=0.05, high=0.3)\n", " threshold_params['nn_size'] = trial.suggest_int(name='nn_size', low=20, high=50)\n", "\n", - " # update the configuration with the new hyperparameters\n", - " model_config.update_config(model_config.config_dict)\n", - "\n", + " # Create a CAREScore object and train+evaluate each dataset for this wind farm\n", " care_score = CAREScore(coverage_beta=0.5, eventwise_f_score_beta=0.5, anomaly_detection_method='criticality')\n", " i = 1\n", " for x_train, y_train, x_test, y_test, event_id in c2c.iter_formatted_datasets(wind_farm=wind_farm, index_column='time_stamp'):\n", @@ -435,7 +341,7 @@ " y_test = y_test.iloc[:N]\n", " \n", " # create a new model using our new configuration and train the model\n", - " model = FaultDetector(model_config)\n", + " model = FaultDetector(Config(config_dict=cfg))\n", " _ = model.fit(x_train, normal_index=y_train, save_models=False)\n", " prediction = model.predict(x_test)\n", " event_info = c2c.event_info_all[c2c.event_info_all['event_id'] == event_id].iloc[0]\n", @@ -449,22 +355,22 @@ " ignore_normal_index=False\n", " )\n", " i += 1\n", + " if i > max_datasets:\n", + " break\n", "\n", " score = care_score.get_final_score()\n", "\n", " return score" ], - "id": "7cee58426b72d9a4", - "outputs": [], - "execution_count": null + "id": "ce3000b2686a463f" }, { "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ - "study = op.create_study(sampler=op.samplers.TPESampler(),\n", - " study_name='care_optimization',\n", - " direction='maximize')\n", + "study = op.create_study(sampler=op.samplers.TPESampler(), study_name='care_optimization', direction='maximize')\n", "\n", "# Ensure that the first trial is done with the hyperparameters of the provided configuration\n", "autoencoder_params = model_config.config_dict['train']['autoencoder']['params']\n", @@ -478,41 +384,37 @@ " 'nn_size': threshold_params['nn_size'],\n", "})\n", "\n", - "# since we loop through many datasets, train many models, we run the garbage collector after each trial\n", + "# Since we loop through many datasets, train many models, we run the garbage collector after each trial\n", "study.optimize(care_objective, n_trials=5, gc_after_trial=True)" ], - "id": "6621d7a2bf3ac717", - "outputs": [], - "execution_count": null + "id": "2063c7f60b979cc5" }, { "metadata": {}, "cell_type": "code", - "source": [ - "study.trials_dataframe()" - ], - "id": "9aa3d678f4fa22e3", "outputs": [], - "execution_count": null + "execution_count": null, + "source": "study.trials_dataframe()", + "id": "95c8498e85467561" } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.6" + "pygments_lexer": "ipython3", + "version": "3.11.8" } }, "nbformat": 4, diff --git a/notebooks/PreDist/PreDist.ipynb b/notebooks/PreDist/PreDist.ipynb new file mode 100644 index 0000000..34ccd88 --- /dev/null +++ b/notebooks/PreDist/PreDist.ipynb @@ -0,0 +1,986 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b3569887686796a6", + "metadata": {}, + "source": [ + "# EnergyFaultDetector @ District Heating\n", + "\n", + "This notebook shows how to apply the EnergyFaultDetector on the PreDist dataset (available on [zenodo](https://doi.org/10.5281/zenodo.17522254)) and how to reproduce results from the accompanying paper (preprint available on [arXiv](https://doi.org/10.48550/arXiv.2511.14791))." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "a149ecfec1850ff7", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:50:14.641401100Z", + "start_time": "2026-01-13T10:50:07.635583200Z" + } + }, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import fbeta_score, precision_score, recall_score, ConfusionMatrixDisplay\n", + "\n", + "from predist_utils import train_or_get_model, find_optimal_threshold, get_arcana_importances, calculate_earliness\n", + "\n", + "from energy_fault_detector.evaluation import PreDistDataset\n", + "from energy_fault_detector import Config\n", + "from energy_fault_detector.utils.visualisation import plot_reconstruction\n", + "from energy_fault_detector.utils.analysis import create_events" + ] + }, + { + "cell_type": "markdown", + "id": "5cab669e1b0c15d", + "metadata": {}, + "source": [ + "### Load the PreDist dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b3f587b734b87ccc", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:50:14.704952800Z", + "start_time": "2026-01-13T10:50:14.647830300Z" + }, + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
substation IDReport dateProblem ENEvent description ENPossible anomaly startPossible anomaly endTraining startTraining endefd_possibleFault labelMonitoring potentialEvent typeEvent endEvent start
Event ID
1102014-05-04 14:44:00no DHWNo hot water. Actuator (DHW system) replaced.2014-05-03 16:00:002014-05-05 04:00:002012-03-28 09:00:002014-04-20 14:44:00TrueMotorised control valve (primary side): Actuat...3.4anomaly2014-05-04 14:44:00NaT
3122015-12-01 10:56:00no heatControl parameters updated.2015-11-29 12:00:002015-12-02 10:56:002015-03-01 00:00:002015-11-17 10:56:00TrueControl unit: Incorrect parameterisation4anomaly2015-12-01 10:56:00NaT
5112018-11-23 08:30:00no heatPump settings updated.NaT2018-11-26 09:56:592015-02-20 14:00:002018-11-09 08:30:00TrueFailure of the heating circuit pump3.8anomaly2018-11-23 08:30:00NaT
6212016-12-06 13:12:00not enough heatThe heaters are not getting warm enough. Suppl...NaT2016-12-07 13:12:002015-11-30 09:00:002016-11-22 13:12:00TrueControl unit: Incorrect parameterisation4anomaly2016-12-06 13:12:00NaT
7262020-06-13 10:38:00no DHWThe needle valve was closed. Readjusted.2020-06-12 12:00:002020-06-14 10:38:002018-10-18 13:00:002020-05-30 10:38:00TrueIncorrect setting of the differential pressure...3.1anomaly2020-06-13 10:38:00NaT
.............................................
585NaTNaNNaNNaTNaT2016-02-29 00:00:002018-02-28 00:00:00NaNNaNNaNnormal2018-03-07 00:00:002018-02-28
5922NaTNaNNaNNaTNaT2018-06-21 10:00:002019-01-31 00:00:00NaNNaNNaNnormal2019-02-07 00:00:002019-01-31
6114NaTNaNNaNNaTNaT2017-12-04 00:00:002019-12-05 00:00:00NaNNaNNaNnormal2019-12-12 00:00:002019-12-05
6619NaTNaNNaNNaTNaT2015-09-15 09:31:002017-06-14 00:00:00NaNNaNNaNnormal2017-06-21 00:00:002017-06-14
6813NaTNaNNaNNaTNaT2017-12-19 00:00:002019-12-20 00:00:00NaNNaNNaNnormal2019-12-27 00:00:002019-12-20
\n", + "

64 rows × 14 columns

\n", + "
" + ], + "text/plain": [ + " substation ID Report date Problem EN \\\n", + "Event ID \n", + "1 10 2014-05-04 14:44:00 no DHW \n", + "3 12 2015-12-01 10:56:00 no heat \n", + "5 11 2018-11-23 08:30:00 no heat \n", + "6 21 2016-12-06 13:12:00 not enough heat \n", + "7 26 2020-06-13 10:38:00 no DHW \n", + "... ... ... ... \n", + "58 5 NaT NaN \n", + "59 22 NaT NaN \n", + "61 14 NaT NaN \n", + "66 19 NaT NaN \n", + "68 13 NaT NaN \n", + "\n", + " Event description EN \\\n", + "Event ID \n", + "1 No hot water. Actuator (DHW system) replaced. \n", + "3 Control parameters updated. \n", + "5 Pump settings updated. \n", + "6 The heaters are not getting warm enough. Suppl... \n", + "7 The needle valve was closed. Readjusted. \n", + "... ... \n", + "58 NaN \n", + "59 NaN \n", + "61 NaN \n", + "66 NaN \n", + "68 NaN \n", + "\n", + " Possible anomaly start Possible anomaly end Training start \\\n", + "Event ID \n", + "1 2014-05-03 16:00:00 2014-05-05 04:00:00 2012-03-28 09:00:00 \n", + "3 2015-11-29 12:00:00 2015-12-02 10:56:00 2015-03-01 00:00:00 \n", + "5 NaT 2018-11-26 09:56:59 2015-02-20 14:00:00 \n", + "6 NaT 2016-12-07 13:12:00 2015-11-30 09:00:00 \n", + "7 2020-06-12 12:00:00 2020-06-14 10:38:00 2018-10-18 13:00:00 \n", + "... ... ... ... \n", + "58 NaT NaT 2016-02-29 00:00:00 \n", + "59 NaT NaT 2018-06-21 10:00:00 \n", + "61 NaT NaT 2017-12-04 00:00:00 \n", + "66 NaT NaT 2015-09-15 09:31:00 \n", + "68 NaT NaT 2017-12-19 00:00:00 \n", + "\n", + " Training end efd_possible \\\n", + "Event ID \n", + "1 2014-04-20 14:44:00 True \n", + "3 2015-11-17 10:56:00 True \n", + "5 2018-11-09 08:30:00 True \n", + "6 2016-11-22 13:12:00 True \n", + "7 2020-05-30 10:38:00 True \n", + "... ... ... \n", + "58 2018-02-28 00:00:00 NaN \n", + "59 2019-01-31 00:00:00 NaN \n", + "61 2019-12-05 00:00:00 NaN \n", + "66 2017-06-14 00:00:00 NaN \n", + "68 2019-12-20 00:00:00 NaN \n", + "\n", + " Fault label \\\n", + "Event ID \n", + "1 Motorised control valve (primary side): Actuat... \n", + "3 Control unit: Incorrect parameterisation \n", + "5 Failure of the heating circuit pump \n", + "6 Control unit: Incorrect parameterisation \n", + "7 Incorrect setting of the differential pressure... \n", + "... ... \n", + "58 NaN \n", + "59 NaN \n", + "61 NaN \n", + "66 NaN \n", + "68 NaN \n", + "\n", + " Monitoring potential Event type Event end Event start \n", + "Event ID \n", + "1 3.4 anomaly 2014-05-04 14:44:00 NaT \n", + "3 4 anomaly 2015-12-01 10:56:00 NaT \n", + "5 3.8 anomaly 2018-11-23 08:30:00 NaT \n", + "6 4 anomaly 2016-12-06 13:12:00 NaT \n", + "7 3.1 anomaly 2020-06-13 10:38:00 NaT \n", + "... ... ... ... ... \n", + "58 NaN normal 2018-03-07 00:00:00 2018-02-28 \n", + "59 NaN normal 2019-02-07 00:00:00 2019-01-31 \n", + "61 NaN normal 2019-12-12 00:00:00 2019-12-05 \n", + "66 NaN normal 2017-06-21 00:00:00 2017-06-14 \n", + "68 NaN normal 2019-12-27 00:00:00 2019-12-20 \n", + "\n", + "[64 rows x 14 columns]" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dataset = PreDistDataset('./predist_data', download_dataset=False)\n", + "# Check events for manufacturer 1\n", + "dataset.events[1]" + ] + }, + { + "cell_type": "markdown", + "id": "ad56a689d11f8d2b", + "metadata": {}, + "source": [ + "### Create or load models (uses optimized configs)\n", + "\n", + "Models defined are:\n", + " - the default autoencoder,\n", + " - conditional autoencoder with day-of-week and hour-of-day time features, and\n", + " - day-of-year autoencoder with day-of-week, hour-of-day and day-of-year time features.\n", + "\n", + "The code size (bottleneck, latent dimension) of the autoencoder is represented as fraction of the input dimension." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c9177963f751115b", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:50:14.854728100Z", + "start_time": "2026-01-13T10:50:14.744744800Z" + } + }, + "outputs": [], + "source": [ + "model_configs = {\n", + " 1: {\n", + " 'config_files': {\n", + " 'Default AE': './configs/m1_default_ae.yaml',\n", + " 'Conditional AE': './configs/m1_cond_ae.yaml',\n", + " 'Day-of-year AE': './configs/m1_doy_ae.yaml'\n", + " },\n", + " 'bottleneck': 0.65,\n", + " },\n", + " 2: {\n", + " 'config_files': {\n", + " 'Default AE': './configs/m2_default_ae.yaml',\n", + " 'Conditional AE': './configs/m2_cond_ae.yaml',\n", + " 'Day-of-year AE': './configs/m2_doy_ae.yaml'\n", + " },\n", + " 'bottleneck': 0.25\n", + " }\n", + "}\n", + "\n", + "time_features = {\n", + " 'Default AE': [],\n", + " 'Conditional AE': ['hour_of_day', 'day_of_week'],\n", + " 'Day-of-year AE': ['hour_of_day', 'day_of_week', 'day_of_year'],\n", + "}\n", + "\n", + "# Model file exists, load the model\n", + "load_from_file = True" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d6f9edc9349242ca", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:42.942459900Z", + "start_time": "2026-01-13T10:50:15.097719300Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[Parallel(n_jobs=100)]: Using backend LokyBackend with 100 concurrent workers.\n", + "[Parallel(n_jobs=100)]: Done 5 out of 64 | elapsed: 7.9s remaining: 1.5min\n", + "[Parallel(n_jobs=100)]: Done 12 out of 64 | elapsed: 9.0s remaining: 38.8s\n", + "[Parallel(n_jobs=100)]: Done 19 out of 64 | elapsed: 9.3s remaining: 22.0s\n", + "[Parallel(n_jobs=100)]: Done 26 out of 64 | elapsed: 9.5s remaining: 14.0s\n", + "[Parallel(n_jobs=100)]: Done 33 out of 64 | elapsed: 9.9s remaining: 9.3s\n", + "[Parallel(n_jobs=100)]: Done 40 out of 64 | elapsed: 10.1s remaining: 6.0s\n", + "[Parallel(n_jobs=100)]: Done 47 out of 64 | elapsed: 10.1s remaining: 3.7s\n", + "[Parallel(n_jobs=100)]: Done 54 out of 64 | elapsed: 10.1s remaining: 1.9s\n", + "[Parallel(n_jobs=100)]: Done 61 out of 64 | elapsed: 10.2s remaining: 0.5s\n", + "[Parallel(n_jobs=100)]: Done 64 out of 64 | elapsed: 10.6s finished\n", + "[Parallel(n_jobs=100)]: Using backend LokyBackend with 100 concurrent workers.\n", + "[Parallel(n_jobs=100)]: Done 5 out of 64 | elapsed: 1.4s remaining: 16.2s\n", + "[Parallel(n_jobs=100)]: Done 12 out of 64 | elapsed: 1.6s remaining: 7.0s\n", + "[Parallel(n_jobs=100)]: Done 19 out of 64 | elapsed: 1.8s remaining: 4.3s\n", + "[Parallel(n_jobs=100)]: Done 26 out of 64 | elapsed: 1.9s remaining: 2.8s\n", + "[Parallel(n_jobs=100)]: Done 33 out of 64 | elapsed: 4.8s remaining: 4.5s\n", + "[Parallel(n_jobs=100)]: Done 40 out of 64 | elapsed: 5.6s remaining: 3.3s\n", + "[Parallel(n_jobs=100)]: Done 47 out of 64 | elapsed: 6.0s remaining: 2.2s\n", + "[Parallel(n_jobs=100)]: Done 54 out of 64 | elapsed: 6.2s remaining: 1.2s\n", + "[Parallel(n_jobs=100)]: Done 61 out of 64 | elapsed: 6.3s remaining: 0.3s\n", + "[Parallel(n_jobs=100)]: Done 64 out of 64 | elapsed: 7.2s finished\n", + "[Parallel(n_jobs=100)]: Using backend LokyBackend with 100 concurrent workers.\n", + "[Parallel(n_jobs=100)]: Done 5 out of 64 | elapsed: 1.4s remaining: 16.9s\n", + "[Parallel(n_jobs=100)]: Done 12 out of 64 | elapsed: 1.6s remaining: 7.0s\n", + "[Parallel(n_jobs=100)]: Done 19 out of 64 | elapsed: 2.1s remaining: 4.9s\n", + "[Parallel(n_jobs=100)]: Done 26 out of 64 | elapsed: 2.4s remaining: 3.5s\n", + "[Parallel(n_jobs=100)]: Done 33 out of 64 | elapsed: 2.5s remaining: 2.4s\n", + "[Parallel(n_jobs=100)]: Done 40 out of 64 | elapsed: 2.5s remaining: 1.5s\n", + "[Parallel(n_jobs=100)]: Done 47 out of 64 | elapsed: 2.6s remaining: 0.9s\n", + "[Parallel(n_jobs=100)]: Done 54 out of 64 | elapsed: 2.6s remaining: 0.5s\n", + "[Parallel(n_jobs=100)]: Done 61 out of 64 | elapsed: 2.7s remaining: 0.1s\n", + "[Parallel(n_jobs=100)]: Done 64 out of 64 | elapsed: 3.2s finished\n", + "[Parallel(n_jobs=100)]: Using backend LokyBackend with 100 concurrent workers.\n", + "[Parallel(n_jobs=100)]: Done 7 out of 56 | elapsed: 1.3s remaining: 8.9s\n", + "[Parallel(n_jobs=100)]: Done 13 out of 56 | elapsed: 1.3s remaining: 4.4s\n", + "[Parallel(n_jobs=100)]: Done 19 out of 56 | elapsed: 1.6s remaining: 3.2s\n", + "[Parallel(n_jobs=100)]: Done 25 out of 56 | elapsed: 1.7s remaining: 2.1s\n", + "[Parallel(n_jobs=100)]: Done 31 out of 56 | elapsed: 1.7s remaining: 1.4s\n", + "[Parallel(n_jobs=100)]: Done 37 out of 56 | elapsed: 1.9s remaining: 1.0s\n", + "[Parallel(n_jobs=100)]: Done 43 out of 56 | elapsed: 1.9s remaining: 0.6s\n", + "[Parallel(n_jobs=100)]: Done 49 out of 56 | elapsed: 2.0s remaining: 0.3s\n", + "[Parallel(n_jobs=100)]: Done 56 out of 56 | elapsed: 2.3s finished\n", + "[Parallel(n_jobs=100)]: Using backend LokyBackend with 100 concurrent workers.\n", + "[Parallel(n_jobs=100)]: Done 7 out of 56 | elapsed: 1.5s remaining: 10.4s\n", + "[Parallel(n_jobs=100)]: Done 13 out of 56 | elapsed: 2.0s remaining: 6.8s\n", + "[Parallel(n_jobs=100)]: Done 19 out of 56 | elapsed: 2.1s remaining: 4.0s\n", + "[Parallel(n_jobs=100)]: Done 25 out of 56 | elapsed: 2.1s remaining: 2.6s\n", + "[Parallel(n_jobs=100)]: Done 31 out of 56 | elapsed: 2.2s remaining: 1.8s\n", + "[Parallel(n_jobs=100)]: Done 37 out of 56 | elapsed: 2.3s remaining: 1.2s\n", + "[Parallel(n_jobs=100)]: Done 43 out of 56 | elapsed: 2.3s remaining: 0.7s\n", + "[Parallel(n_jobs=100)]: Done 49 out of 56 | elapsed: 2.4s remaining: 0.3s\n", + "[Parallel(n_jobs=100)]: Done 56 out of 56 | elapsed: 2.6s finished\n", + "[Parallel(n_jobs=100)]: Using backend LokyBackend with 100 concurrent workers.\n", + "[Parallel(n_jobs=100)]: Done 7 out of 56 | elapsed: 1.5s remaining: 10.7s\n", + "[Parallel(n_jobs=100)]: Done 13 out of 56 | elapsed: 1.6s remaining: 5.4s\n", + "[Parallel(n_jobs=100)]: Done 19 out of 56 | elapsed: 1.9s remaining: 3.7s\n", + "[Parallel(n_jobs=100)]: Done 25 out of 56 | elapsed: 1.9s remaining: 2.4s\n", + "[Parallel(n_jobs=100)]: Done 31 out of 56 | elapsed: 2.2s remaining: 1.8s\n", + "[Parallel(n_jobs=100)]: Done 37 out of 56 | elapsed: 2.2s remaining: 1.1s\n", + "[Parallel(n_jobs=100)]: Done 43 out of 56 | elapsed: 2.2s remaining: 0.7s\n", + "[Parallel(n_jobs=100)]: Done 49 out of 56 | elapsed: 2.3s remaining: 0.3s\n", + "[Parallel(n_jobs=100)]: Done 56 out of 56 | elapsed: 2.9s finished\n" + ] + } + ], + "source": [ + "# Create or load model, predict and collect results for all events\n", + "from joblib import Parallel, delayed\n", + "\n", + "\n", + "# Select which dataset(s) and model configs to test:\n", + "manufacturers = [1, 2]\n", + "models = ['Default AE', 'Conditional AE', 'Day-of-year AE']\n", + "\n", + "results = {}\n", + "for manufacturer in manufacturers:\n", + " results[manufacturer] = {}\n", + " for model_name, config_file in model_configs[manufacturer]['config_files'].items():\n", + " if model_name not in models:\n", + " continue\n", + "\n", + " # get configuration and time features\n", + " conf = Config(config_file)\n", + "\n", + " # Prepare parameters for parallel execution\n", + " bottleneck_ratio = model_configs[manufacturer]['bottleneck']\n", + " events_to_process = dataset.events[manufacturer].index\n", + "\n", + " # Run parallel over events\n", + " # n_jobs=-1 uses all CPU cores. Adjust if memory is an issue.\n", + " parallel_results = Parallel(n_jobs=-1, verbose=10)(\n", + " delayed(train_or_get_model)(\n", + " event_id, dataset, manufacturer, model_name,\n", + " conf, bottleneck_ratio, load_from_file, time_features[model_name]\n", + " ) for event_id in events_to_process\n", + " )\n", + "\n", + " # Create the results dictionary\n", + " results[manufacturer][model_name] = dict(parallel_results)" + ] + }, + { + "cell_type": "markdown", + "id": "3c0ee0eeabed5068", + "metadata": {}, + "source": [ + "### Find optimal criticality threshold based on the reliability score\n", + "Calculate max criticality before the report timestamp and optimize criticality threshold. We use cross-validation to find the criticality threshold to prevent overfitting, so the model will generalize better to unseen data." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4f741a688f149cd7", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:46.183337500Z", + "start_time": "2026-01-13T10:51:43.076058800Z" + } + }, + "outputs": [], + "source": [ + "max_criticality_results = {}\n", + "criticality_thresholds = {}\n", + "predicted_anomalies = {}\n", + "true_anomalies = {}\n", + "\n", + "for manufacturer in results.keys():\n", + " # prepare result dictionaries\n", + " max_criticality_results[manufacturer] = {}\n", + " criticality_thresholds[manufacturer] = {}\n", + " predicted_anomalies[manufacturer] = {}\n", + "\n", + " # save true anomalies for easy access later\n", + " true_anomalies[manufacturer] = (dataset.events[manufacturer]['Event type'] == 'anomaly').astype(int)\n", + "\n", + " for model_name, results_dict in results[manufacturer].items():\n", + "\n", + " # calculate max criticality for each event\n", + " max_criticality_list = []\n", + " for event_id, prediction in results_dict.items():\n", + " event_row = dataset.events[manufacturer].loc[event_id]\n", + " max_criticality = prediction.criticality().loc[:event_row['Report date']].max()\n", + " max_criticality_list += [(event_id, max_criticality)]\n", + "\n", + " # Transform results to pandas series with max criticality with event id as index\n", + " c = pd.DataFrame(max_criticality_list, columns=['event_id', 'max_criticality'])\n", + " c = c.set_index('event_id')['max_criticality']\n", + " max_criticality_results[manufacturer][model_name] = c\n", + "\n", + " criticality_threshold, _ = find_optimal_threshold(\n", + " true_anomalies=true_anomalies[manufacturer],\n", + " max_criticalities=max_criticality_results[manufacturer][model_name],\n", + " )\n", + " criticality_thresholds[manufacturer][model_name] = criticality_threshold\n", + " predicted_anomalies[manufacturer][model_name] = c > criticality_threshold" + ] + }, + { + "cell_type": "markdown", + "id": "ae299a2f61b2f5a5", + "metadata": {}, + "source": [ + "### Final results (reliability and eventwise precision+recall)\n", + "\n", + "Note: The trained models may not reproduce the exact results reported in the paper due to random initialization, hardware differences, and random seeds. In practice, it is advisable to train each model 5–10 times and select the best-performing run for the target application." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3b5c248383e4592e", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:47.519369900Z", + "start_time": "2026-01-13T10:51:46.252375100Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Manufacturer m1\n", + "Model Default AE:\n", + "Reliability: 0.87, Precision: 0.95, Recall: 0.66, Earliness: 0.59\n", + "Model Conditional AE:\n", + "Reliability: 0.90, Precision: 1.00, Recall: 0.66, Earliness: 0.60\n", + "Model Day-of-year AE:\n", + "Reliability: 0.92, Precision: 1.00, Recall: 0.69, Earliness: 0.65\n", + "Manufacturer m2\n", + "Model Default AE:\n", + "Reliability: 0.64, Precision: 0.77, Recall: 0.38, Earliness: 0.35\n", + "Model Conditional AE:\n", + "Reliability: 0.74, Precision: 0.82, Recall: 0.54, Earliness: 0.52\n", + "Model Day-of-year AE:\n", + "Reliability: 0.73, Precision: 0.86, Recall: 0.46, Earliness: 0.44\n" + ] + }, + { + "data": { + "image/png": "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g4ODA4YNG4aEhIQC269evYrly5cDADp27AgABe7wtGTJEgBAp06d9FaXr68vkpKScO7cOeW6e/fuYefOnSr7PXnypMBz8++M9PJtyPOVL18e9evXx7p161SaH3/88Qd++ukn5fs0B5aWlgVG86xcubLAKKL8JlNhjS5dDR8+HLm5uYiIiMDatWthZWWFoUOHahxVFBQUpLIUdgt5daZMmYJZs2ZhxowZr1M+ERERyRRH2hAREb3E19cXGzduRN++fVGzZk2EhISgdu3ayMrKwqlTp7B161aEhYUBAOrVq4fQ0FCsXbsWiYmJCAgIwK+//op169ahe/fuaN26td7q6tevHz744AO88847GDduHNLT07F69WpUr15dZQLeuXPn4tixY+jUqRO8vb3x4MEDfPHFF6hYsSLeeuutIo+/cOFCdOjQAc2aNcPQoUPx7NkzrFy5Ei4uLpg9e7be3sfr6ty5M6Kjo+Hi4gJ/f3/ExsbiwIEDKF26tMp+9evXh6WlJT7//HMkJSVBoVCgTZs2KFeunE6vFxkZie+++w5RUVGoWLEigLwm0aBBg7B69WqMGjVKb+/tZfXq1UO9evU07peUlISVK1cCgPLW7v/973/h6uoKV1dXjBkzxmA1EhERkeGwaUNERFSIrl274ty5c1i4cCG+/fZbrF69GgqFAnXr1sXixYsxfPhw5b7/93//hypVqiAqKgo7d+6Eh4cHpk+fjlmzZum1ptKlS2Pnzp2YNGkSpk2bhsqVK2PevHm4fPmyStOma9euuHHjBr7++ms8evQIZcqUQUBAAObMmaOc2LcwQUFB2LdvH2bNmoWZM2fC2toaAQEB+Pzzz3WetNeQli9fDktLS8TExCAjIwMtWrTAgQMHCtwJysPDA2vWrMG8efMwdOhQ5OTk4PDhwzo1be7cuYOJEyeiS5cuCA0NVa4fOHAgtm/fjmnTpqFDhw4m/3yePn1aYDRO/uVj3t7ebNoQERHJlCS0mS2QiIiIiIiIiIiMinPaEBERERERERGZITZtiIiIiIiIiIjMEJs2RERERERERERmiE0bIiIiIiIiIiIzxKYNEREREREREZEZYtOGiIiIiIiIiMgMsWlDRERERERERGSG2LQhIiIiIiIiIjJDbNoQEREREREREZkhNm2IiIiIiIiIiMwQmzZERERERERERGaITRsiIiIiIiIi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HvObb74BAHh7e0OSJOzduxcPHz5EamoqnJycMGXKFEycOBHr1q3D1atX8dtvv2HlypXKibhGjhyJy5cvY+rUqYiPj8fGjRsRFRWl0/utVq0abt26hc2bN+Pq1atYsWJFoROg2draIjQ0FL///juOHz+OcePGoU+fPvDw8AAAzJkzB/PmzcOKFSvw119/4fz584iMjMSSJUt0qoeISA6YFcwKIiJ1mBPMiRLJxHPqkAy9OGmYLtvv3bsnQkJCRJkyZYRCoRBVqlQRw4cPF0lJSUKIvEnCxo8fL5ydnYWrq6uYNGmSCAkJKXLSMCGEePbsmZg4caIoX768sLGxEVWrVhVff/21cvvcuXOFh4eHkCRJhIaGCiHyJjpbtmyZ8PPzE9bW1qJs2bIiODhYHD16VPm8PXv2iKpVqwqFQiFatmwpvv76a50nDZs6daooXbq0cHR0FH379hVLly4VLi4uyu2zZs0S9erVE1988YXw9PQUtra2olevXuLJkycqx42JiRH169cXNjY2ws3NTbRq1Urs2LFDCMFJw4jIfDErCsesICLKw5woHHOCXiYJUcSMTEREREREREREZDK8PIqIiIiIiIiIyAyxaUNEREREREREZIbYtCEiIiIiIiIiMkNs2hARERERERERmSE2bYiIiIiIiIiIzBCbNkREREREREREZohNGyIiIiIiIiIiM8SmDRERERERERGRGWLThoiIiIiIiIjIDLFpQ0RERERERERkhti0ISIiIiIiIiIyQ2zaEBERERERERGZof8HjWZepymyOYwAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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wNvbG5IkYefOnbh//z7S0tLg5OSE8ePHY8yYMVi1ahUSExPx22+/YdmyZcqJuN577z1cvnwZEyZMwKVLl7BmzRpER0fr9HmrV6+OGzduYN26dUhMTMTSpUsLnQDN1tYW4eHhOHPmDI4cOYKRI0eiR48e8PDwAABMnz4ds2bNwtKlS/HXX3/hjz/+QFRUFBYuXKhTPUREcsCsYFYQEanDnGBOWCQTz6lDMvT8pGG6bL9z547o16+fKFeunFAoFMLX11cMHjxYpKSkCCHyJgkbNWqUcHZ2Fq6urmLs2LGiX79+RU4aJoQQT58+FWPGjBEVK1YUNjY2olq1auLbb79Vbp8xY4bw8PAQkiSJ8PBwIUTeRGeLFy8W/v7+onTp0qJ8+fIiNDRUHDp0SPm+HTt2iGrVqgmFQiGaN28uvv32W50nDZswYYIoW7ascHR0FD179hSLFi0SLi4uyu1Tp04VdevWFV999ZXw9PQUtra2olu3buLhw4cqx42NjRX16tUTNjY2ws3NTbRo0UJs2bJFCMFJw4jIfDErCsesICLKw5woHHOCXiQJUcSMTEREREREREREZDK8PYqIiIiIiIiIyAyxaUNEREREREREZIbYtCEiIiIiIiIiMkNs2hARERERERERmSE2bYiIiIiIiIiIzBCbNkREREREREREZohNGyIiIiIiIiIiM8SmDRERERERERGRGWLThoiIiIiIiIjIDLFpQ0RERERERERkhti0ISIiIiIiIiIyQ2zaEBERERERERGZof8HM/I1TOXvAdoAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "for manufacturer in results.keys():\n", + " print(f'Manufacturer m{manufacturer}')\n", + "\n", + " fig, ax = plt.subplots(1, 3, figsize=(12, 3))\n", + " \n", + " for model_name, ax_ in zip(results[manufacturer].keys(), ax):\n", + " print(f'Model {model_name}:')\n", + "\n", + " reliability = fbeta_score(\n", + " true_anomalies[manufacturer], predicted_anomalies[manufacturer][model_name],\n", + " beta=0.5\n", + " )\n", + " precision = precision_score(\n", + " true_anomalies[manufacturer], predicted_anomalies[manufacturer][model_name]\n", + " )\n", + " recall = recall_score(\n", + " true_anomalies[manufacturer], predicted_anomalies[manufacturer][model_name]\n", + " )\n", + "\n", + " # Average earliness score over reports\n", + " earliness_scores = []\n", + " for event_id, predictions in results[manufacturer][model_name].items():\n", + " if dataset.events[manufacturer].loc[event_id]['Event type'] == 'anomaly':\n", + " criticality = predictions.criticality()\n", + " detection_time, earliness = calculate_earliness(\n", + " criticality_threshold=criticality_thresholds[manufacturer][model_name],\n", + " report_ts=dataset.events[manufacturer].loc[event_id]['Report date'],\n", + " criticality=criticality\n", + " )\n", + " earliness_scores.append(earliness)\n", + " avg_earliness = sum(earliness_scores) / len(earliness_scores)\n", + "\n", + " print(f'Reliability: {reliability:.2f}, Precision: {precision:.2f}, Recall: {recall:.2f}, Earliness: {avg_earliness:.2f}')\n", + "\n", + " disp = ConfusionMatrixDisplay.from_predictions(\n", + " y_true=true_anomalies[manufacturer], y_pred=predicted_anomalies[manufacturer][model_name],\n", + " cmap='Blues',\n", + " labels=[False, True],\n", + " display_labels=['Normal', 'Anomaly'],\n", + " ax=ax_\n", + " )\n", + " ax_.set_title(model_name)\n", + " \n", + " fig.suptitle(f'Confusion matrix - M{manufacturer}')\n", + " plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "id": "570ebfedc795dd61", + "metadata": {}, + "source": [ + "### Visualise results\n", + "\n", + "Here we show how to visualise results for a specific event and model.\n", + "\n", + "For the event chosen, 49 of manufacturer 1, the customer called because of lack of hot water. It turned out to be an operating error where the DHW controller was set to night mode, leading to a very low setpoint for the DHW storage temperatures" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a761ecfa7874e175", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:47.618674Z", + "start_time": "2026-01-13T10:51:47.602650600Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "substation ID 18\n", + "Report date 2019-05-04 07:19:00\n", + "Problem EN no DHW\n", + "Event description EN The DHW controller was set to night mode. Rese...\n", + "Possible anomaly start 2019-05-03 11:00:00\n", + "Possible anomaly end 2019-05-05 07:19:00\n", + "Training start 2016-12-16 10:00:00\n", + "Training end 2019-04-20 07:19:00\n", + "efd_possible True\n", + "Fault label Control unit: Incorrect parameterisation\n", + "Monitoring potential 4\n", + "Event type anomaly\n", + "Event end 2019-05-04 07:19:00\n", + "Event start NaT\n", + "Name: 49, dtype: object" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Choose event and model to plot results for\n", + "manufacturer = 1\n", + "model_name = 'Conditional AE'\n", + "event_id = 49\n", + "report_date = dataset.events[manufacturer].loc[event_id]['Report date']\n", + "\n", + "# Event details\n", + "dataset.events[manufacturer].loc[event_id]" + ] + }, + { + "cell_type": "markdown", + "id": "e1d0ace838ed0d5f", + "metadata": {}, + "source": [ + "#### Criticality\n", + "\n", + "Plot the criticality and the incident report timestamp." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5236c23c789fdd72", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:47.805480100Z", + "start_time": "2026-01-13T10:51:47.662858400Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "Text(0.5, 0, '')" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "predictions = results[manufacturer][model_name][event_id]\n", + "\n", + "fig, ax = plt.subplots(1, 1, figsize=(8,3))\n", + "crit = predictions.criticality()\n", + "crit.plot(ax=ax, label=model_name)\n", + "\n", + "if pd.notna(report_date):\n", + " ax.axvline(report_date, label='incident report', c='r', linestyle='-')\n", + "\n", + "ax.legend(loc='upper left')\n", + "ax.set_ylabel('criticality')\n", + "ax.set_xlabel('')" + ] + }, + { + "cell_type": "markdown", + "id": "814da89e48b3860c", + "metadata": {}, + "source": [ + "#### ARCANA results\n", + "\n", + "Here we first determine anomalous events detected by the model and then calculate the ARCANA feature importances for the longest detected anomaly event.\n", + "Afterward, we plot the top 3 features with the highest ARCANA feature importances." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b8a234e31eeecc66", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:58.483098200Z", + "start_time": "2026-01-13T10:51:47.845224700Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "s_dhw_supply_temperature_setpoint 0.268510\n", + "s_hc1_supply_temperature_setpoint 0.215204\n", + "p_hc1_return_temperature 0.118098\n", + "p_net_supply_temperature 0.110434\n", + "outdoor_temperature 0.082548\n", + "s_dhw_lower_storage_temperature 0.058807\n", + "s_hc1_supply_temperature 0.058639\n", + "s_dhw_supply_temperature 0.029270\n", + "p_net_meter_flow 0.024234\n", + "p_net_return_temperature 0.023280\n", + "s_dhw_upper_storage_temperature 0.006409\n", + "p_net_meter_heat_power 0.004566\n", + "dtype: float32" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_data = dataset.get_event_data(manufacturer, event_id)['test_data']\n", + "\n", + "# find longest detected anomaly event (continuous run of predicted anomalous timestamps)\n", + "anomaly_events, _ = create_events(\n", + " test_data,\n", + " predictions.predicted_anomalies,\n", + " min_event_length=12\n", + ")\n", + "longest_anomaly_event = anomaly_events[anomaly_events['duration'] == anomaly_events['duration'].max()].iloc[0]\n", + "\n", + "# Calculate ARCANA feature importances\n", + "top_features = get_arcana_importances(manufacturer, event_id, model_name, test_data.loc[longest_anomaly_event['start']:report_date])\n", + "\n", + "top_features" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "d0cbfbada56ec052", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-13T10:51:59.243154700Z", + "start_time": "2026-01-13T10:51:58.602602600Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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+z+ATqfzJHzypvW94grmzJ2L0Whk8ODBGE1mrrjiCr7+5hv27t3Llf0H+K5fyjg3Xbt2o2vXbjz11NN0796dr77+hst79aZWrRh27NjpU84/W7diNBp96rp+/QafZTZs3OgZ9Nua37RTKbLM+g0baNGypc80Q6FyO3bsROfOnZk77yO++PJLZs6c6bNsaUwmMyiKz/KdOnVi1+7dtGhZ8lgz4EmwnD1eSOF65c8zGk0YTWb0BiMul4st/2yla9euAOzevZvU1FRatWqF0WSmY8eO7Nu/v+Rt58Wm8HYA/vprLT169OCBCRO80xISE1EK7ZvFYkXTUn3Wi61dB4CUU6eJruXphrdt2468epu99QZ81uvSpStut5szqan06uXbWqos+rxm1Q0bNWbc+PGMGz+eJ554glkffsiEBx+kU+fOfPvttzRp2hRDMeOXAJjNFrSz6pR/vDf+vYlGjT2tqc6cOcPevXtp3boNRpOZli1b8tdfa33WW7tuHU2bNsVoyo+lgt5gKPH1lk+n02E0mbHljV2g0xtKfd25VQ293kBAYIi3+3GVCj23L0cF701f5Rmf6nzWOZuRkutUnOI6qeWzWEube1Y5gaV386lMthLGOivMYit7DA3vsuU4fj7OGmuwstYJDC5+HIny7GNFsFZRzMuznfK8ZqvahVy34pT39RQaUfRmS2G2vNOb/owZe3oJrRTOl6J4xpY6u3tSQASEVcYGL1BZWYBnmIPM1iPZu/sMkYEmImuXMs5haMWPCWixle8zraRxK6vqXFNR/PlMAgiNii1zmcjY4mNmCTjHYxIaWfYyZwmvVbfU+edcl0oUFVPyuFClvZ5sgaF+b6Os4+IPc2nvyRIEBoeXeB1QmrDoOuVep7CIWnHntX55BAaFEngu11QXsRr/9L2aYO3atbz44ots2LCBQ4cOMX/+fJKTk2nRovQB/B588EFefvllFixYwK5du7j//vt9npLmr969e5ORkcFPP/3kfRpe3759+fTTT4mNjaVp09IHDAZISEjgiSeeYM2aNRw8eJDffvuNvXv3evehX79+bNiwgY8++oi9e/cyefLkIk89Azh06BAPP/wwu3fv5vPPP+ett97iwQcf9Flm1apVTJs2jT179vD222/z9ddfF1nmbKNHj+bll19G0zRuuMH/gXDr16/Pn3/+ydGjR0lJ8XTHevzxx1m9ejXjx49n8+bN7N27l++//77IU/7OhdFo5IEHHmDt2rVs3LiRUaNGcdlll3mTVM888wwfffQRU6dOZfv27ezcuZMvvviCp556qtRymzRpwoYNG1i0aBF79uzh6aefZv369UX29Z9//mH37t2kpKTgdDpp3LgxcXFxTJkyhb179/Lzzz8zffr0MvejadOm3Hrrrdx+++3Mnz+fhIQE1q1bx0svvcTPP/9c5voTJ05k0aJFJCQk8Pfff7N06VLva2ncuHGcPn2aESNGsH79evbv38+iRYu48847vU9yqV+/PmvXriUxMdH7VL58zz77LEuWLGHbtm2MGjWKyMhIb8uzRx55hCVLlvDcc8+xZ88e5s2bx8yZM3n00UfLrHNJ4uPjURSFn376ieTkZJ+nNAohhLi0eIcSqv5GY5cELX8MKBn0XQghaixJSlWB4OBg/vzzT6666iqaNm3KU089xfTp0xkypPSn1DzyyCOMHDmSO+64g+7duxMUFFSuhEu+sLAw2rRpQ1RUFM2bNwc8iSpVVenTp49fZdhsNnbt2sVNN91E06ZNueeeexg3bhz33nsvAIMGDeLpp59m0qRJdOnShYyMDG6//fYi5dx+++3k5OTQtWtXxo0bx4MPPsg999xTZL83bNhAhw4deP7553nttdcYNGhQkbIKGzFiBAaDgREjRpSrFcqzzz5LYmIijRo1IirKcxetbdu2LF++nD179tCrVy86dOjAM888Q+3atf0utyQ2m43HH3+cW265hZ49exIYGMiXX37pnT9o0CB++uknfvvtN7p06cJll13GjBkziI8v/U7Dvffey4033sjw4cPp1q0bp06d4v777/dZZsyYMTRr1ozOnTsTFRXFqlWrMBqNfP755+zatYu2bdvyyiuv8Pzzz/u1L3PmzOH222/nkUceoVmzZgwdOpT169dTr17ZT1dzu92MGzeOFi1aMHjwYJo2bco777wDQO3atVm1ahVut5uBAwfSpk0bJk6cSGhoqLfb46OPPoper6dly5ZERUVx6NAhb9kvv/wyDz74IJ06deLEiRP8+OOPmPJaQXXs2JGvvvqKL774gtatW/PMM8/w7LPPMmrUKL/2uTh16tRh6tSp/Oc//6FWrVoVkrwUQghRM+nykiMX2HOELlr5h1lyUkIIUXPV+KfviZqjb9++tG/fntdff73EZerX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kfyWUtJQSgghaq7zSkplZmayf/9+Ro4cSadOnTAajSxZsoSbbroJgN27d3Po0CG6d+9eIZUtlt7oabFUHfTGci3eqVMn799btmxh6dKlBAYGFllu//79pKamYrfbufLKK4sta+fOnbRr186bkALo2bMnqqqye/dub1KqVatW6PV67zKxsbFs3boVgAEDBhAfH0/Dhg0ZPHgwgwcP5oYbbsBms5W5L507d/ZvpwvVt7T98cf+/ftxOp307NnTO81oNNK1a1d27tzps2zbtm29f8fGxgKQlJREvXr1znn7QgghhBClUfLa7MhDWqqGPH1PCCFqvnIlpR599FGuvfZa4uPjOXbsGJMnT0av1zNixAhCQkK4++67efjhhwkPDyc4OJgHHniA7t27V+6T9xSlXF3oqlPhBFJmZqa3VdPZYmNjOXDgQIVs02j0TZwpioKqqgAEBQXx999/s2zZMn777TeeeeYZpkyZwvr168scXLzwvgDodLoiF2CFx6461+6d56rwfnsHHc3bbyGEEEKIyiC5kaqV3yJNDrsQQtRc5Rro/MiRI4wYMYJmzZoxbNgwIiIi+Ouvv7xdr2bMmME111zDTTfdRO/evYmJiWH+/PmVUvGarmPHjmzfvp369evTuHFjn5+AgACaNGmC1WplyZIlxa7fokULtmzZQlZWlnfaqlWr0Ol0NGvWzO96GAwG+vfvz7Rp0/jnn39ITEz0jmllMplwu91+lRMVFcXx48d9pm3evNn7d1n7kz+GVGnba9SoESaTyWdsKKfTyfr162nZsqVf9RRCCCGEqCyKt/uetJSqCjLQuRBC1Hzlain1xRdflDrfYrHw9ttv8/bbb59XpS4F48aN44MPPmDEiBFMmjSJ8PBw9u3bxxdffMGsWbOwWCw8/vjjTJo0CZPJRM+ePUlOTmb79u3cfffd3HrrrUyePJk77riDKVOmkJyczAMPPMDIkSO9XffK8tNPP3HgwAF69+5NWFgYCxcuRFVVb1Krfv36rF27lsTERAIDA30GEj9bv379+N///sdHH31E9+7d+eSTT9i2bZt3jKqy9ic6Ohqr1cqvv/5K3bp1sVgshISE+GwjICCA++67j8cee4zw8HDq1avHtGnTyM7O5u677z7HSAghhBBCVIyC7nvVXJFLhHegc8lJCSFEjVWullKi4tSuXZtVq1bhdrsZOHAgbdq0YeLEiYSGhqLTecLy9NNP88gjj/DMM8/QokULhg8fTlJSEgA2m41FixZx+vRpunTpws0338yVV17JzJkz/a5DaGgo8+fPp1+/frRo0YL33nuPzz//nFatWgGe7pp6vZ6WLVsSFRXFoUOHSixr0KBBPP3000yaNIkuXbqQkZHB7bff7rNMaftjMBh48803ef/996lduzbXX399sdt5+eWXuemmmxg5ciQdO3Zk3759LFq0yO8nAAohhBBCVJa8SzgZ6LyKqDLQuRBC1HiKdoGNxJienk5ISAhpaWkEBwf7zMvNzSUhIYEGDRrIU9SEEDWKnL+EEOLit/tEBgu3HqdumJV/dY6r7upcnLKyIO9BQX9tSWTNSTvt64VyRbPoaq6YEEKIwkrL7RQmLaWEEEIIIYSoAPktdi6oO74XMW/3vWqthRBCiPMhSSkhhBBCCCEqgC4/KXVhdUS4aBV035O0lBBC1FSSlBJCCCGEEKJCyEDnVSn/OOskJyWEEDWWJKWEEEIIIYSoADrpvlelCrrvSVZKCCFqKklKCSGEEEIIUQHyu5Gp0lSqSsjT94QQouaTpJQQQgghhBAVID83IjmpKpJ3nCUpJYQQNZckpYQQQgghhKgAOiV/TCnJSlUFLS8rJd33hBCi5pKklBBCCCGEEBVAkTGlqpSqen5LSykhhKi5JCklhBBCCCFEBZKGUlUj/zDrJCslhBA1liSlqkjfvn2ZOHFihZU3ZcoU2rdvX+oyo0aNYujQoRW2TSGEEEIIUTKdTgY6r1IyppQQQtR4kpSqIvPnz+e5556rsPIeffRRlixZUmHl+cufZNiFqibXXQghhBAXPhnovGqp+Ump6q2GEEKI82Co7gpcKsLDwyu0vMDAQAIDAyu0zMrkcDgwmUzVsm1N03C73dWybSGEEEJcOrwDnVdzPS4deQOdS1MpIYSosWp+SylNg6ys6vkpx22ws7vv1a9fnxdffJG77rqLoKAg6tWrx//93//5rHPkyBFGjBhBeHg4AQEBdO7cmbVr1wJFW/243W4efvhhQkNDiYiIYNKkSUWe/KKqKi+99BINGjTAarXSrl07vvnmG+/8ZcuWoSgKS5YsoXPnzthsNnr06MHu3bsBmDt3LlOnTmXLli0oioKiKMydO7fY/c3vOvjCCy9Qu3ZtmjVrBsDhw4cZNmwYoaGhhIeHc/3115OYmFhkvalTpxIVFUVwcDBjx47F4XB4l7Hb7UyYMIHo6GgsFguXX34569evL7Ifv/zyC506dcJsNvPJJ5/4XXchhBBCiHPhHehcmkpVCU267wkhRI1X81tKZWdDdbUYysyEgIBzXn369Ok899xz/Pe//+Wbb77hvvvuo0+fPjRr1ozMzEz69OlDnTp1+OGHH4iJieHvv/9GzX/MSDFlzZ07l9mzZ9OiRQumT5/Od999R79+/bzLvPTSS3zyySe89957NGnShD///JPbbruNqKgo+vTp413uySefZPr06URFRTF27FjuuusuVq1axfDhw9m2bRu//vorixcvBiAkJKTE/VuyZAnBwcH8/vvvADidTgYNGkT37t1ZsWIFBoOB559/nsGDB/PPP/94W1ItWbIEi8XCsmXLSExM5M477yQiIoIXXngBgEmTJvHtt98yb9484uPjmTZtGoMGDWLfvn0+LdL+85//8Oqrr9KwYUMsFguPPPKI33UXQgghhCgv6b5XtfK778lA50IIUXPV/KRUDXbVVVdx//33A/D4448zY8YMli5dSrNmzfjss89ITk5m/fr13kRL48aNSyzr9ddf54knnuDGG28E4L333mPRokXe+Xa7nRdffJHFixfTvXt3ABo2bMjKlSt5//33fZJSL7zwgvf///znP1x99dXk5uZitVoJDAzEYDAQExNT5v4FBAQwa9Ysb7Lpk08+QVVVZs2a5W1mPWfOHEJDQ1m2bBkDBw4EwGQyMXv2bGw2G61ateLZZ5/lscce47nnniMnJ4d3332XuXPnMmTIEAA++OADfv/9dz788EMee+wx7/afffZZBgwY4P2/PHUXQgghhCiv/OsbGei8amj53fequR5CCCHOXc1PStlsnhZL1bXt89C2bVvv34qiEBMTQ1JSEgCbN2+mQ4cOfo1FlZaWxvHjx+nWrZt3msFgoHPnzt7m4/v27SM7O9snSQOesZ46dOhQYr1iY2MBSEpKol69euXavzZt2viMI7Vlyxb27dtHUFCQz3K5ubns37/f+3+7du2wFTq23bt3JzMzk8OHD5OWlobT6aRnz57e+Uajka5du7Jz506fcjt37lyu+gohhBBCnA9v973qrcYlI7//gDSUEkKImqvmJ6UU5by60FUno9Ho87+iKN7ueVartUK3lZmXuPv555+pU6eOzzyz2Vxi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8lsuTZ06laioKIKDgxk7dmyJiZdnn32W1q1bF5nevn17nn766VL3Z8qUKcybN4/vv//eO+bZsmXLADh8+DDDhg0jNDSU8PBwrr/+ehITE4vU88UXX6RWrVqEhoby7LPP4nK5eOyxxwgPD6du3brMmTPHu05iYiKKovDFF1/Qo0cPLBYLrVu3Zvny5T712rZtG0OGDCEwMJBatWoxcuRIUlJSvPP79u3L+PHjmThxIpGRkQwaNAiA1157jTZt2hAQEEBcXBz3338/mZmZACxbtow777yTtLQ0775OmTIFKL6VT2hoqLe1UH69v/zyS/r06YPFYuHTTz8FYNasWbRo0QKLxULz5s155513Sj3m+RwOB+PHjyc2NhaLxUJ8fDwvvfSSd35qaiqjR4/2vgb69evHli1bAM9rberUqWzZssW7L/l1VRSFd999lyFDhmC1WmnYsCHffPONz7a3bt1Kv379vO+9e+65x3ucCse28PGeMGECkyZNIjw8nJiYGO+xA8/YegA33HADiqJ4/xeiIjjdKkG5x2lwegXulAPVXR1xFtVVkIhS7f59houqk+1w8elfh5izKrG6qyKEEKIcCreUKk+bqQMpns/i9By5kVeTlWtMqcI+/PBDhgwZQu3atb3T7rnnHu/fbdq0ITY2liuvvJL9+/fTqFGjYssxm82YzeZzrQZOt8bbS/ed8/rnY9wVjTEZyn7k+/HjxxkxYgTTpk3jhhtuICMjgxUrVviV1Fq6dCmxsbEsXbqUffv2MXz4cNq3b8+YMWMAuP3221mzZg1vvvkm7dq1IyEhwSehcT7GjRuHw+Hgzz//JCAggB07dhAYGFiuMvbt28dXX33Fjz/+SHp6OnfffTf333+/N8kBsGTJEiwWC8uWLSMxMZE777yTiIgIXnjhhSLl3XXXXUydOpX169fTpUsXADZt2sQ///zD/PnzS63Lo48+ys6dO0lPT/cmj8LDw3E6nQwaNIju3buzYsUKDAYDzz//PIMHD+aff/7BZDIB8Mcff1C3bl3+/PNPVq1axd13383q1avp3bs3a9eu5csvv+Tee+9lwIAB1K1b17vdxx57jNdff52WLVvy2muvce2115KQkEBERASpqan069eP0aNHM2PGDHJycnj88ccZNmwYf/zxh7eMefPmcd9997Fq1SrvNJ1Ox5tvvkmDBg04cOAA999/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f+vKsPrzn//MGWecgd/v5+STT2bbtm28+eabLF26lMzMTC688EIOHz7ca5vdcsstFBYWkpWVxac//emUII9lWdx6661MmzYNv9/PokWL+Otf/5r8e9e2evLJJ1myZAler5eXXnqJnTt3cvHFF1NcXExmZiYnn3wyzzzzTPJ5Z511Fnv37uVLX/pSctt39cnixYtTtsvtt9/O1KlTj6qvhRDiWHC4LcoDr+xhW31b/wvut4+t1G8e/UYd55RSNHbEMC3FgaYQLeH4eDdJCCGEECPMNd4NOFpxU3HnczvG5bWve+cMPC5t0Ms/8MADXH311bzxxhusWrWKa6+9lsrKSq655poBn/uTn/yE733ve/zXf/0Xf/3rX/nMZz7DmWeeyezZs2lvb+fMM89kypQpPProo5SUlLBmzZoRzS569tlnycrK4umnnwYgHo9z/vnns3z5cl588UVcLhff//73ueCCC3jrrbe48cYb2bJlC62trdx3330A5OXl8corr6Ss1xmUc/4cCoX44Q9/yD333EN+fj5FRUUDbof+vPHGG5xyyik888wzzJ8/H4/HA8CDDz7Id77zHX7xi19w4oknsnbtWq655hoyMjK46qqrks+/6aabuP3226msrOSTn/wkV1xxBcFgkJ/97GcEAgEuu+wyvvOd73D33XenbDOfz8fKlSvZs2cP//Ef/0F+fj7//d//DcCtt97K73//e375y18yc+ZMXnjhBT72sY9RWFjImWeemVzP17/+dX784x9TXV1Nbm4u+/fv56KLLuK///u/8Xq9/Pa3v+W9730vW7dupbKykocffphFixZx7bXXDuqz1dNQ+7prWwohxGT21KY6GjtiPP5WLbPODaZfUHePXaOOc7sOt/HSq6/gzqvkUBgUBl86r//jvRBCCCEml0kflJpMKioquO2229A0jdmzZ7NhwwZuu+22QQUOLrroIj772c8C8LWvfY3bbruN5557jtmzZ/PQQw9x+PBh3nzzTfLy8gCYMWPGiLY9IyODe+65JxmA+P3vf49lWdxzzz3JTJz77ruPnJwcVq5cyXnnnYff7ycajVJSUtLnOhOWRWN7jPaofeezoT1GUdQenhaPx7nrrrt6DTfsbzv0p7CwEID8/PyU9tx000385Cc/4QMf+ABgZ1Rt3ryZX/3qVylBqRtvvJHzzz8fgC984Qtcfvnl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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the reconstruction of the top 3\n", + "fig, ax = plot_reconstruction(test_data, predictions.reconstruction, top_features.index[:3].to_list())\n", + "\n", + "for ax_ in ax:\n", + " ax_.axvline(report_date, label='incident report', color='r', linestyle='-')\n", + "\n", + "ax[0].legend(loc='upper left')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "efd", + "language": "python", + "name": "efd" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/PreDist/configs/m1_cond_ae.yaml b/notebooks/PreDist/configs/m1_cond_ae.yaml new file mode 100644 index 0000000..d314d40 --- /dev/null +++ b/notebooks/PreDist/configs/m1_cond_ae.yaml @@ -0,0 +1,56 @@ +train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + params: + imputer_strategy: mean + include_duplicate_value_to_nan: false, + max_col_zero_frac: 0.5 + max_nan_frac_per_col: 0.8 + min_unique_value_count: 3 + scale: standardize + features_to_exclude: + - p_net_meter_energy # we use power and flow + - p_net_meter_volume + + autoencoder: + name: conditional + verbose: 0 + params: + act: prelu + last_act: linear + batch_size: 256 + code_size: 5 # set by bottleneck ratio (0.65) + early_stopping: true + min_delta: 0.0001 + patience: 5 + epochs: 100 + layers: [64, 32] + learning_rate: 0.0004486710512068144 + noise: 0.05 + loss_name: mean_squared_error + conditional_features: ['hour_of_day_sine', + 'hour_of_day_cosine', + 'day_of_week_sine', + 'day_of_week_cosine'] + + anomaly_score: + name: mahalanobis + + data_splitter: + type: sklearn + validation_split: 0.2 + shuffle: true + + threshold_selector: + fit_on_val: false + name: quantile + params: + quantile: 0.99 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/notebooks/PreDist/configs/m1_default_ae.yaml b/notebooks/PreDist/configs/m1_default_ae.yaml new file mode 100644 index 0000000..bd8f9f8 --- /dev/null +++ b/notebooks/PreDist/configs/m1_default_ae.yaml @@ -0,0 +1,52 @@ +train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + params: + imputer_strategy: mean + include_duplicate_value_to_nan: false, + max_col_zero_frac: 0.5 + max_nan_frac_per_col: 0.8 + min_unique_value_count: 3 + scale: standardize + features_to_exclude: + - p_net_meter_energy # we use power and flow + - p_net_meter_volume + + autoencoder: + name: default + verbose: 0 + params: + act: prelu + last_act: linear + batch_size: 256 + code_size: 5 # set by bottleneck ratio (0.65) + early_stopping: true + min_delta: 0.0001 + patience: 5 + epochs: 100 + layers: [64, 32] + learning_rate: 0.0004486710512068144 + noise: 0.05 + loss_name: mean_squared_error + + anomaly_score: + name: mahalanobis + + data_splitter: + type: sklearn + validation_split: 0.2 + shuffle: true + + threshold_selector: + fit_on_val: false + name: quantile + params: + quantile: 0.99 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/notebooks/PreDist/configs/m1_doy_ae.yaml b/notebooks/PreDist/configs/m1_doy_ae.yaml new file mode 100644 index 0000000..e2fc23d --- /dev/null +++ b/notebooks/PreDist/configs/m1_doy_ae.yaml @@ -0,0 +1,58 @@ +train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + params: + imputer_strategy: mean + include_duplicate_value_to_nan: false, + max_col_zero_frac: 0.5 + max_nan_frac_per_col: 0.8 + min_unique_value_count: 3 + scale: standardize + features_to_exclude: + - p_net_meter_energy # we use power and flow + - p_net_meter_volume + + autoencoder: + name: conditional + verbose: 0 + params: + act: prelu + last_act: linear + batch_size: 256 + code_size: 5 # set by bottleneck ratio (0.65) + early_stopping: true + min_delta: 0.0001 + patience: 5 + epochs: 100 + layers: [64, 32] + learning_rate: 0.0004486710512068144 + noise: 0.05 + loss_name: mean_squared_error + conditional_features: ['hour_of_day_sine', + 'hour_of_day_cosine', + 'day_of_week_sine', + 'day_of_week_cosine', + 'day_of_year_sine', + 'day_of_year_cosine'] + + anomaly_score: + name: mahalanobis + + data_splitter: + type: sklearn + validation_split: 0.2 + shuffle: true + + threshold_selector: + fit_on_val: false + name: quantile + params: + quantile: 0.99 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/notebooks/PreDist/configs/m2_cond_ae.yaml b/notebooks/PreDist/configs/m2_cond_ae.yaml new file mode 100644 index 0000000..a171ac4 --- /dev/null +++ b/notebooks/PreDist/configs/m2_cond_ae.yaml @@ -0,0 +1,65 @@ +train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + params: + imputer_strategy: mean + include_duplicate_value_to_nan: false, + max_col_zero_frac: 0.5 + max_nan_frac_per_col: 0.8 + min_unique_value_count: 2 + scale: standardize + features_to_exclude: + - p_net_meter_energy # we use power and flow + - p_net_meter_volume + - s_dhw_control_unit_mode + - s_hc1.1_control_unit_mode + - s_hc1.2_control_unit_mode + - s_hc1.3_control_unit_mode + - s_hc1_control_unit_mode + - s_hc1.1_room_temperature_setpoint + - s_hc1.2_room_temperature_setpoint + - s_hc1.3_room_temperature_setpoint + - s_hc1_room_temperature_setpoint + + autoencoder: + name: conditional + verbose: 0 + params: + act: prelu + last_act: linear + batch_size: 256 + code_size: 5 # set by bottleneck ratio (0.25) + early_stopping: true + min_delta: 0.0001 + patience: 5 + epochs: 100 + layers: [64, 32] + learning_rate: 0.0005289609464733553 + noise: 0.15 + loss_name: mean_squared_error + conditional_features: [ 'hour_of_day_sine', + 'hour_of_day_cosine', + 'day_of_week_sine', + 'day_of_week_cosine' ] + + anomaly_score: + name: rmse + + data_splitter: + type: sklearn + validation_split: 0.2 + shuffle: true + + threshold_selector: + fit_on_val: false + name: quantile + params: + quantile: 0.99 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/notebooks/PreDist/configs/m2_default_ae.yaml b/notebooks/PreDist/configs/m2_default_ae.yaml new file mode 100644 index 0000000..c5a188e --- /dev/null +++ b/notebooks/PreDist/configs/m2_default_ae.yaml @@ -0,0 +1,61 @@ +train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + params: + imputer_strategy: mean + include_duplicate_value_to_nan: false, + max_col_zero_frac: 0.5 + max_nan_frac_per_col: 0.8 + min_unique_value_count: 2 + scale: standardize + features_to_exclude: + - p_net_meter_energy # we use power and flow + - p_net_meter_volume + - s_dhw_control_unit_mode + - s_hc1.1_control_unit_mode + - s_hc1.2_control_unit_mode + - s_hc1.3_control_unit_mode + - s_hc1_control_unit_mode + - s_hc1.1_room_temperature_setpoint + - s_hc1.2_room_temperature_setpoint + - s_hc1.3_room_temperature_setpoint + - s_hc1_room_temperature_setpoint + + autoencoder: + name: default + verbose: 0 + params: + act: prelu + last_act: linear + batch_size: 256 + code_size: 5 # set by bottleneck ratio (0.25) + early_stopping: true + min_delta: 0.0001 + patience: 5 + epochs: 100 + layers: [64, 32] + learning_rate: 0.0005289609464733553 + noise: 0.15 + loss_name: mean_squared_error + + anomaly_score: + name: rmse + + data_splitter: + type: sklearn + validation_split: 0.2 + shuffle: true + + threshold_selector: + fit_on_val: false + name: quantile + params: + quantile: 0.99 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/notebooks/PreDist/configs/m2_doy_ae.yaml b/notebooks/PreDist/configs/m2_doy_ae.yaml new file mode 100644 index 0000000..5acfac6 --- /dev/null +++ b/notebooks/PreDist/configs/m2_doy_ae.yaml @@ -0,0 +1,67 @@ +train: + data_clipping: + lower_percentile: 0.001 + upper_percentile: 0.999 + + data_preprocessor: + params: + imputer_strategy: mean + include_duplicate_value_to_nan: false, + max_col_zero_frac: 0.5 + max_nan_frac_per_col: 0.8 + min_unique_value_count: 2 + scale: standardize + features_to_exclude: + - p_net_meter_energy # we use power and flow + - p_net_meter_volume + - s_dhw_control_unit_mode + - s_hc1.1_control_unit_mode + - s_hc1.2_control_unit_mode + - s_hc1.3_control_unit_mode + - s_hc1_control_unit_mode + - s_hc1.1_room_temperature_setpoint + - s_hc1.2_room_temperature_setpoint + - s_hc1.3_room_temperature_setpoint + - s_hc1_room_temperature_setpoint + + autoencoder: + name: conditional + verbose: 0 + params: + act: prelu + last_act: linear + batch_size: 256 + code_size: 5 # set by bottleneck ratio (0.25) + early_stopping: true + min_delta: 0.0001 + patience: 5 + epochs: 100 + layers: [64, 32] + learning_rate: 0.0005289609464733553 + noise: 0.15 + loss_name: mean_squared_error + conditional_features: [ 'hour_of_day_sine', + 'hour_of_day_cosine', + 'day_of_week_sine', + 'day_of_week_cosine', + 'day_of_year_sine', + 'day_of_year_cosine' ] + + anomaly_score: + name: rmse + + data_splitter: + type: sklearn + validation_split: 0.2 + shuffle: true + + threshold_selector: + fit_on_val: false + name: quantile + params: + quantile: 0.99 + +root_cause_analysis: + alpha: 0.8 + init_x_bias: recon + num_iter: 1000 diff --git a/notebooks/PreDist/predist_utils.py b/notebooks/PreDist/predist_utils.py new file mode 100644 index 0000000..484e101 --- /dev/null +++ b/notebooks/PreDist/predist_utils.py @@ -0,0 +1,246 @@ +import os +from typing import List, Tuple +from pathlib import Path +from copy import deepcopy +import gc + +# suppress tensorflow warnings +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" + +import pandas as pd +import numpy as np +import tensorflow as tf +from sklearn.model_selection import StratifiedKFold +from sklearn.metrics import fbeta_score + +from energy_fault_detector import FaultDetector, Config +from energy_fault_detector.core import FaultDetectionResult +from energy_fault_detector.evaluation import PreDistDataset +from energy_fault_detector.root_cause_analysis.arcana_utils import calculate_mean_arcana_importances + + +def train_or_get_model(event_id: int, dataset: PreDistDataset, manufacturer: int, model_name: str, conf: Config, + bottleneck_ratio: float, load_from_file: bool, time_features: List[str] | None + ) -> Tuple[int, FaultDetectionResult]: + """Processes a single event: loads data, trains/loads model, and predicts. + + Args: + event_id (int): ID of the event to process. + dataset (PreDistDataset): Dataset containing the event data. + manufacturer (int): Manufacturer ID for the event. + model_name (str): Name of the model to use for training. + conf (Config): Base configuration for training. + bottleneck_ratio (float): Ratio to determine the bottleneck size for the autoencoder. + load_from_file (bool): Whether to load the model from file if available. Otherwise, train and save a new model. + time_features (List[str] | None): List of time features to use for conditional autoencoders. + + Returns: + Tuple[int, FaultDetectionResult]: A tuple containing the event ID and the fault detection result. + """ + + # Local copy of time features and configuration to avoid mutation issues in parallel + ts_features = time_features.copy() if time_features else None + local_conf = deepcopy(conf) + + # Get specific event data + data = dataset.get_event_data(manufacturer, event_id) + train_data = data['train_data'] + test_data = data['test_data'] + + # Create a new model or load from file + model_path = Path(f'./models/m{manufacturer}/event_{event_id}/{model_name}') + + if model_path.exists() and load_from_file: + model = FaultDetector() + model.load_models(model_path) + if (model_path / 'ts_features.txt').exists(): + with open(model_path / 'ts_features.txt', 'r') as f: + ts_features = f.read().splitlines() + else: + # Add the code size to the AE configuration, based on the bottleneck ratio + # code_size is part of the model configuration, so we overwrite the parameter of the underlying dictionary. + bottleneck = calculate_bottleneck(train_data, local_conf, bottleneck_ratio) + local_conf['train']['autoencoder']['params']['code_size'] = bottleneck + + # For the conditional autoencoders, add time features + if ts_features: + train_data = add_cyclic_time_features(train_data, ts_features) + + model = FaultDetector(local_conf, model_directory=model_path) + model_data = model.fit(train_data, data['train_normal_flag'], save_models=True, overwrite_models=True) + if ts_features: + # For the conditional autoencoders, save the time features as well + with open(Path(model_data.model_path) / 'ts_features.txt', 'w') as f: + f.write('\n'.join(ts_features)) + + # Predict + if ts_features: + # For the conditional autoencoders, add time features + test_data = add_cyclic_time_features(test_data, ts_features) + predictions = model.predict(test_data) + + # memory cleanup + del model + tf.keras.backend.clear_session() + gc.collect() + + return event_id, predictions + + +def add_cyclic_time_features(df: pd.DataFrame, features: List[str]) -> pd.DataFrame: + """Calculates cyclical time features from the timestamp index.""" + df = df.copy() + if 'hour_of_day' in features: + phase = df.index.hour / 24 + df['hour_of_day_sine'] = np.sin(2 * np.pi * phase) + df['hour_of_day_cosine'] = np.cos(2 * np.pi * phase) + if 'day_of_week' in features: + phase = df.index.day_of_week / 7 + df['day_of_week_sine'] = np.sin(2 * np.pi * phase) + df['day_of_week_cosine'] = np.cos(2 * np.pi * phase) + if 'day_of_year' in features: + phase = df.index.dayofyear / (365 + df.index.is_leap_year) + df['day_of_year_sine'] = np.sin(2 * np.pi * phase) + df['day_of_year_cosine'] = np.cos(2 * np.pi * phase) + return df + + +def calculate_bottleneck(df: pd.DataFrame, config: Config, ratio: float) -> int: + """Calculates code_size (the bottleneck of the autoencoder) relative to input dimensions, accounting for excluded + features and conditional features. + + Args: + df (pd.DataFrame): Input dataframe to determine the number of input features of the AE. + config (Config): Configuration for the AE. + ratio (float, optional): Ratio between input and bottleneck dimensions. + + Returns: + int: The calculated bottleneck size for the autoencoder. + """ + + # Get the conditional features from the config + ae_params = config['train']['autoencoder']['params'] + cond_features = ae_params.get('conditional_features', []) + + # Exclude conditions (not compressed) + input_dim = len(df.columns) - len([c for c in cond_features if c in df.columns]) + + # Check for feature exclusions in config + excluded = [] + dp_config = config['train'].get('data_preprocessor', {}) + if dp_config.get('params'): + # params-based data prep config + excluded = dp_config.get('params').get('features_to_exclude', []) + else: + # steps-based data prep config + steps = config['train']['data_preprocessor'].get('steps', []) + for step in steps: + if step['name'] == 'column_selector': + excluded = step['params'].get('features_to_exclude', []) + break + + # Remove the excluded features from the input dimension + input_dim -= len([e for e in excluded if e in df.columns]) + + return max(1, round(input_dim * ratio)) + + +def find_optimal_threshold(true_anomalies: pd.Series, + max_criticalities: pd.Series, + thresholds: np.ndarray = np.arange(1, 100), + k: int = 5) -> Tuple[int, float]: + """Finds the threshold maximizing reliability (Event-wise F0.5) using CV. + + Args: + true_anomalies (pd.Series): Series indicating whether each event is an anomaly. 1 = anomaly, 0 = normal. + max_criticalities (pd.Series): Series containing the maximum criticality of each event. + thresholds (np.ndarray, optional): Array of thresholds to evaluate. Defaults to np.arange(1, 100). + k (int, optional): Number of folds for CV. Defaults to 5. + + Returns: + Optimal criticality threshold and avg validation reliability score. + """ + y_true = true_anomalies.values + max_criticalities = max_criticalities.values + skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42) + + chosen_thresholds = [] + val_scores = [] + + for train_idx, val_idx in skf.split(y_true, y_true): + y_train, max_crit_train = y_true[train_idx], max_criticalities[train_idx] + y_val, max_crit_val = y_true[val_idx], max_criticalities[val_idx] + + # Best threshold on training data + best_t = None + best_train_f05 = -1.0 + for t in thresholds: + y_pred_train = (max_crit_train >= t).astype(int) + f05_train = fbeta_score(y_train, y_pred_train, beta=0.5, zero_division=0) + if f05_train > best_train_f05: + best_train_f05 = f05_train + best_t = t + + chosen_thresholds.append(best_t) + + # Evaluate on validation data + y_pred_val = (max_crit_val >= best_t).astype(int) + f05_val = fbeta_score(y_val, y_pred_val, beta=0.5, zero_division=0) + val_scores.append(f05_val) + + robust_t = int(np.median(chosen_thresholds)) + mean_val_f05 = float(np.mean(val_scores)) + + return robust_t, mean_val_f05 + + +def get_arcana_importances(manufacturer: int, event_id: int, config_name: str, data: pd.DataFrame) -> pd.Series: + """Get ARCANA importances for a given event.""" + + model_path = Path(f'models/m{manufacturer}/event_{event_id}/{config_name}') + model = FaultDetector() + model.load_models(model_path) + + # Load the time features and add them to the data if available (for the conditional autoencoders) + if (model_path / 'ts_features.txt').exists(): + with open(model_path / 'ts_features.txt', 'r') as f: + ts_features = f.read().splitlines() + data = add_cyclic_time_features(data, ts_features) + + bias, _, _ = model.run_root_cause_analysis(data, track_losses=False, track_bias=False) + return calculate_mean_arcana_importances(bias).sort_values(ascending=False) + + +def calculate_earliness(criticality_threshold: int, report_ts: int | pd.Timestamp, criticality: pd.Series, + min_detection_time: pd.Timedelta = pd.Timedelta(hours=24) + ) -> Tuple[int | pd.Timestamp | None, float]: + """Calculate the detection time and earliness score: + + E = max(0, min(1, (report_ts - detection_timestamp) / min_detection_time)) + + The earliness score is 1 if the fault is detected at least min_detection_time before the report and 0 if the + fault is detected after the report or not detected at all. Between min_detection_time before the report and the + report timestamp, the earliness score linearly decreases to 0. + + Args: + criticality_threshold (int): Threshold for determining whether the event is detected. + report_ts (int | pd.Timestamp): Timestamp of the report. + criticality (pd.Series): Series containing the criticality pd.Series of each event. + min_detection_time (pd.Timedelta, optional): Minimum detection time. Defaults to pd.Timedelta(hours=24). + + Returns: + A tuple containing the detection time and earliness score. If not detected, the detection time is None and + the earliness score is 0. + """ + + crit_threshold_reached = criticality[criticality >= criticality_threshold] + if crit_threshold_reached.empty: + detection_time = None + earliness = 0 + return detection_time, earliness + + detection_timestamp = crit_threshold_reached.sort_index(ascending=True).index[0] + detection_time = report_ts - detection_timestamp + # max(earliness, 0) to handle detection after the fault is known + earliness = max(min(1, detection_time / min_detection_time), 0) + return detection_time, earliness diff --git a/tests/config/test_config.py b/tests/config/test_config.py index ba39c46..a1a147e 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -3,7 +3,6 @@ import shutil import unittest -import numpy as np from energy_fault_detector.config import Config, InvalidConfigFile PROJECT_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '..') @@ -22,10 +21,7 @@ def test_init(self): self.assertDictEqual(conf.config_dict['train'], { 'anomaly_score': {'name': 'mahalanobis', 'params': {'pca': True, 'pca_min_var': 0.85}}, - 'data_preprocessor': {'params': {'max_nan_frac_per_col': 0.05, - 'imputer_strategy': 'mean', - 'features_to_exclude': ['feature1', 'feature2'], - 'include_duplicate_value_to_nan': False}}, + 'data_preprocessor': None, # unspecified, default pipeline 'autoencoder': {'name': 'MultilayerAutoencoder', 'verbose': 0, 'params': {'layers': [300], @@ -39,7 +35,7 @@ def test_init(self): 'threshold_selector': {'name': 'FDR', 'params': {'target_false_discovery_rate': 0.8}, 'fit_on_val': False}, - 'data_splitter': {'train_block_size': 7, 'val_block_size': 3, 'type': 'DataSplitter'}, + 'data_splitter': {'train_block_size': 7, 'val_block_size': 3, 'type': 'BlockDataSplitter'}, 'data_clipping': {'lower_percentile': 0.01, 'upper_percentile': 0.99} }) self.assertDictEqual(conf.config_dict['root_cause_analysis'], diff --git a/tests/config/test_quickstart_config.py b/tests/config/test_quickstart_config.py new file mode 100644 index 0000000..ae55b30 --- /dev/null +++ b/tests/config/test_quickstart_config.py @@ -0,0 +1,47 @@ +from unittest import TestCase +from typing import Any, Dict + +from energy_fault_detector.config.config import Config +from energy_fault_detector.config.quickstart_config import generate_quickstart_config # adjust + + +class TestQuickstartConfig(TestCase): + def test_generate_quickstart_config_valid_dict(self) -> None: + """Should return a valid config dict that Config accepts and includes required sections.""" + cfg: Dict[str, Any] = generate_quickstart_config( + output_path=None, + angle_columns=["theta_deg"], + counter_columns=["energy_total_kwh"], + scaler="standard", + imputer_strategy="mean", + early_stopping=False, + ) + + # Basic structure checks + self.assertIn("train", cfg) + train = cfg["train"] + self.assertIn("data_preprocessor", train) + self.assertIn("steps", train["data_preprocessor"]) + self.assertIn("autoencoder", train) + self.assertIn("params", train["autoencoder"]) + self.assertIn("threshold_selector", train) + self.assertIn("params", train["threshold_selector"]) + + # Ensure certain steps exist + step_names = [s["name"] for s in train["data_preprocessor"]["steps"]] + self.assertIn("column_selector", step_names) + self.assertIn("simple_imputer", step_names) + self.assertTrue( + any(n in ("standard_scaler", "minmax_scaler") for n in step_names), + "Expected a scaler step in the pipeline." + ) + + # Should not raise: validate via Config + Config(config_dict=cfg) + + def test_generate_quickstart_config_validation_split_guard(self) -> None: + """If validation split not in (0, 1) it should raise ValueError.""" + with self.assertRaises(ValueError): + _ = generate_quickstart_config( + validation_split=0.0, # invalid by design + ) diff --git a/tests/core/test_fault_detection_result.py b/tests/core/test_fault_detection_result.py new file mode 100644 index 0000000..813d688 --- /dev/null +++ b/tests/core/test_fault_detection_result.py @@ -0,0 +1,73 @@ + +import unittest +import tempfile +import pandas as pd +from pathlib import Path + +from energy_fault_detector.core import FaultDetectionResult + + +class TestFaultDetectionResultSaveLoad(unittest.TestCase): + + def setUp(self): + # Create sample data + index = pd.date_range("2023-01-01", periods=5, freq="H") + + self.predicted_anomalies = pd.Series([False, True, False, True, False], index=index, name="anomaly") + self.reconstruction = pd.DataFrame({ + "sensor_1": [1.0, 2.0, 3.0, 4.0, 5.0], + "sensor_2": [2.0, 3.0, 4.0, 5.0, 6.0] + }, index=index) + self.recon_error = pd.DataFrame({ + "sensor_1": [0.1, 0.2, 0.1, 0.3, 0.1], + "sensor_2": [0.2, 0.1, 0.3, 0.1, 0.2] + }, index=index) + self.anomaly_score = pd.Series([0.1, 0.9, 0.2, 0.8, 0.15], index=index, name="score") + + # Optional fields + self.bias_data = pd.DataFrame({"bias": [0.01, 0.02]}, index=index[:2]) + self.arcana_losses = pd.DataFrame({"loss_a": [0.1, 0.2], "loss_b": [0.05, 0.1]}, index=index[:2]) + self.tracked_bias = [ + pd.DataFrame({"bias_step_0": [0.01]}, index=[index[0]]), + pd.DataFrame({"bias_step_1": [0.02]}, index=[index[1]]) + ] + + # Instantiate object + self.fdr = FaultDetectionResult( + predicted_anomalies=self.predicted_anomalies, + reconstruction=self.reconstruction, + recon_error=self.recon_error, + anomaly_score=self.anomaly_score, + bias_data=self.bias_data, + arcana_losses=self.arcana_losses, + tracked_bias=self.tracked_bias + ) + + def test_save_and_load_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Save result + self.fdr.save(tmp_path) + + # Load result back + loaded_fdr = FaultDetectionResult.load(tmp_path) + + # Compare core attributes + pd.testing.assert_series_equal(loaded_fdr.predicted_anomalies, self.fdr.predicted_anomalies, check_freq=False) + pd.testing.assert_frame_equal(loaded_fdr.reconstruction, self.fdr.reconstruction, check_freq=False) + pd.testing.assert_frame_equal(loaded_fdr.recon_error, self.fdr.recon_error, check_freq=False) + pd.testing.assert_series_equal(loaded_fdr.anomaly_score, self.fdr.anomaly_score, check_freq=False) + + # Compare optional attributes + pd.testing.assert_frame_equal(loaded_fdr.bias_data, self.fdr.bias_data, check_freq=False) + pd.testing.assert_frame_equal(loaded_fdr.arcana_losses, self.fdr.arcana_losses, check_freq=False) + + # Compare tracked_bias list of DataFrames + self.assertEqual(len(loaded_fdr.tracked_bias), len(self.fdr.tracked_bias)) + for loaded_df, original_df in zip(loaded_fdr.tracked_bias, self.fdr.tracked_bias): + pd.testing.assert_frame_equal(loaded_df, original_df, check_freq=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_model_factory.py b/tests/core/test_model_factory.py index 90d4ca2..28ffef5 100644 --- a/tests/core/test_model_factory.py +++ b/tests/core/test_model_factory.py @@ -27,7 +27,6 @@ def test_model_creation(self): # Test for data preprocessor data_preprocessor = model_factory.data_preprocessor self.assertIsInstance(data_preprocessor, DataPreprocessor) - self.assertEqual(data_preprocessor.max_nan_frac_per_col, 0.05) # Test for threshold selector threshold_selector = model_factory.threshold_selector diff --git a/tests/data_preprocessing/test_column_selector.py b/tests/data_preprocessing/test_column_selector.py index 78a2c84..761f40e 100644 --- a/tests/data_preprocessing/test_column_selector.py +++ b/tests/data_preprocessing/test_column_selector.py @@ -51,3 +51,35 @@ def test_missing_columns(self): def test_not_fitted(self): with self.assertRaises(NotFittedError): self.column_selector.transform(self.raw_dataframe) + + def test_fit_with_features_to_select(self): + # Select a mix (case-insensitive); Sensor_3 should be dropped due to NaN fraction (0.5 >= 0.2) + selector = ColumnSelector(max_nan_frac_per_col=0.2, + features_to_select=['sensor_1', 'SENSOR_2', 'sensor_3', 'sensor_5']) + selector.fit(self.raw_dataframe) + expected_attributes = ["Sensor_1", "Sensor_2", "Sensor_5"] + assert_array_equal(expected_attributes, selector.feature_names_out_) + + def test_transform_with_features_to_select(self): + # Keep only Sensor_1 and Sensor_5 + selector = ColumnSelector(max_nan_frac_per_col=0.2, + features_to_select=['sensor_1', 'sensor_5']) + expected_df = self.raw_dataframe[["Sensor_1", "Sensor_5"]] + df = selector.fit_transform(self.raw_dataframe) + # Check values and column order + assert_array_equal(expected_df.columns.values, df.columns.values) + assert_array_equal(expected_df.values, df.values) + + def test_features_to_select_case_insensitive(self): + # Mixed casing in selection should match columns + selector = ColumnSelector(max_nan_frac_per_col=0.2, + features_to_select=['SeNsOr_1', 'seNSor_5']) + selector.fit(self.raw_dataframe) + expected_attributes = ["Sensor_1", "Sensor_5"] + assert_array_equal(expected_attributes, selector.feature_names_out_) + + def test_init_mutually_exclusive_args(self): + with self.assertRaises(ValueError): + ColumnSelector(max_nan_frac_per_col=0.2, + features_to_exclude=['sensor_1'], + features_to_select=['sensor_1', 'sensor_5']) diff --git a/tests/data_preprocessing/test_counter_diff_transformer.py b/tests/data_preprocessing/test_counter_diff_transformer.py new file mode 100644 index 0000000..9b4c178 --- /dev/null +++ b/tests/data_preprocessing/test_counter_diff_transformer.py @@ -0,0 +1,365 @@ +import unittest +from datetime import datetime, timedelta +from typing import List + +import numpy as np +import pandas as pd + +from energy_fault_detector.data_preprocessing.counter_diff_transformer import CounterDiffTransformer + + +class TestCounterDiffTransformer(unittest.TestCase): + """Unit tests for CounterDiffTransformer.""" + + def setUp(self) -> None: + """Create small helper datasets used across tests.""" + # Regular 1-second interval index + self.t0 = datetime(2024, 1, 1, 0, 0, 0) + self.idx_1s = pd.date_range(self.t0, periods=5, freq="1s", tz="UTC") + + def _df( + self, + values_a: List[float], + values_b: List[float] | None = None, + index: pd.DatetimeIndex | None = None, + ) -> pd.DataFrame: + """Helper to build a DataFrame with optional second counter.""" + index = index if index is not None else self.idx_1s + if len(values_a) < len(index): + index = index[:len(values_a)] + data = {"counter_a": values_a} + if values_b is not None: + data["counter_b"] = values_b + return pd.DataFrame(data, index=index) + + def test_fit_requires_datetime_index_when_rate_or_mask(self) -> None: + """fit should error on non-DatetimeIndex when rate/mask are requested.""" + df = pd.DataFrame({"counter_a": [0, 1, 2]}, index=[0, 1, 2]) + + # compute_rate=True requires DatetimeIndex + with self.assertRaises(ValueError): + CounterDiffTransformer(counters=["counter_a"], compute_rate=True).fit(df) + + # gap_policy='mask' requires DatetimeIndex + with self.assertRaises(ValueError): + CounterDiffTransformer(counters=["counter_a"], gap_policy="mask").fit(df) + + # If neither rate nor mask, fit should succeed + CounterDiffTransformer(counters=["counter_a"], gap_policy="ignore").fit(df) + + def test_fit_requires_monotonic_index(self) -> None: + """fit should error on non-monotonic DatetimeIndex when rate/mask are requested.""" + idx = pd.DatetimeIndex( + [self.t0, self.t0 + timedelta(seconds=2), self.t0 + timedelta(seconds=1)], + tz="UTC", + ) + df = self._df(values_a=[0, 1, 2], index=idx) + with self.assertRaises(ValueError): + CounterDiffTransformer(counters=["counter_a"], compute_rate=True).fit(df) + + # No problem if index is sorted + CounterDiffTransformer(counters=["counter_a"], compute_rate=True).fit(df.sort_index()) + + def test_diff_zero_strategy_default(self) -> None: + """Default 'zero' strategy: negative diff -> increment equals current value.""" + # 0 -> 1 -> 3 -> 0 (reset) -> 2 + df = self._df(values_a=[0, 1, 4, 1, 3]) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="zero", + fill_first="nan", + keep_original=False, + gap_policy="ignore", + ).fit(df) + + out = tr.transform(df) + self.assertListEqual(list(out.columns), ["counter_a_diff"]) + + expected = pd.Series([np.nan, 1, 3, 1, 2], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out["counter_a_diff"], expected, check_dtype=False) + + def test_diff_fill_first_zero(self) -> None: + """First increment filled with zero when fill_first='zero'.""" + df = self._df(values_a=[5, 7, 8, 10, 12]) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="zero", + fill_first="zero", + keep_original=False, + gap_policy="ignore", + ).fit(df) + + out = tr.transform(df) + expected = pd.Series([0, 2, 1, 2, 2], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out["counter_a_diff"], expected, check_dtype=False) + + def test_rollover_strategy_with_value(self) -> None: + """'rollover' strategy uses provided rollover value to compute increment.""" + # 95 -> 98 -> 2 (rollover at 100) => inc: NaN/0, 3, 2 + (100 - 98) = 4 + df = self._df(values_a=[95, 98, 2, 7, 20]) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="rollover", + rollover_values={"counter_a": 100.0}, + fill_first="zero", + keep_original=False, + gap_policy="ignore", + ).fit(df) + + out = tr.transform(df) + expected = pd.Series([0, 3, 4, 5, 13], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out["counter_a_diff"], expected, check_dtype=False) + + def test_rollover_strategy_without_value_errors(self) -> None: + """'rollover' without a rollover_value should raise a ValueError.""" + df = self._df(values_a=[50, 10]) # negative diff + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="rollover", + rollover_values={}, # missing + fill_first="zero", + keep_original=False, + gap_policy="ignore", + ).fit(df) + with self.assertRaises(ValueError): + tr.transform(df) + + def test_nan_strategy(self) -> None: + """'nan' strategy sets negative diffs to NaN.""" + df = self._df(values_a=[10, 8, 9]) + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="nan", + fill_first="zero", + keep_original=False, + gap_policy="ignore", + ).fit(df) + out = tr.transform(df) + expected = pd.Series([0, np.nan, 1], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out["counter_a_diff"], expected, check_dtype=False) + + def test_auto_strategy_prefers_rollover_when_available(self) -> None: + """'auto' uses rollover if a value is supplied; else behaves like 'zero'.""" + df = self._df(values_a=[95, 98, 2]) + + # With rollover value -> like 'rollover' + tr1 = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="auto", + rollover_values={"counter_a": 100.0}, + fill_first="zero", + gap_policy="ignore", + ).fit(df) + out1 = tr1.transform(df) + expected1 = pd.Series([0, 3, 4], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out1["counter_a_diff"], expected1, check_dtype=False) + + # Without rollover value -> like 'zero' + tr2 = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="auto", + rollover_values={}, # none + fill_first="zero", + gap_policy="ignore", + ).fit(df) + out2 = tr2.transform(df) + expected2 = pd.Series([0, 3, 2], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out2["counter_a_diff"], expected2, check_dtype=False) + + def test_small_negative_tolerance(self) -> None: + """Small negative diff within tolerance is clamped to zero.""" + df = self._df(values_a=[10.0, 9.9995, 10.5]) + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + reset_strategy="zero", + small_negative_tolerance=0.01, + fill_first="zero", + gap_policy="ignore", + ).fit(df) + out = tr.transform(df) + # diff: 0, -0.0005 (-> 0), 0.5005 + expected = pd.Series([0.0, 0.0, 0.5005], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out["counter_a_diff"], expected) + + def test_compute_rate(self) -> None: + """Rate equals increment divided by dt seconds.""" + idx = pd.DatetimeIndex( + [self.t0, self.t0 + timedelta(seconds=2), self.t0 + timedelta(seconds=5)], + tz="UTC", + ) + df = self._df(values_a=[0, 4, 7], index=idx) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=True, + reset_strategy="zero", + fill_first="zero", + gap_policy="ignore", + ).fit(df) + out = tr.transform(df) + # increments: [0, 4, 3]; dt: [NaN, 2, 3]; rate: [0, 2, 1] + expected = pd.Series([0.0, 2.0, 1.0], index=df.index, name="counter_a_rate") + pd.testing.assert_series_equal(out["counter_a_rate"], expected) + + def test_gap_masking_with_max_gap_seconds(self) -> None: + """Values at positions where dt > threshold should be masked (NaN).""" + idx = pd.DatetimeIndex( + [ + self.t0, + self.t0 + timedelta(seconds=1), + self.t0 + timedelta(seconds=10), # big gap from previous + self.t0 + timedelta(seconds=11), + ], + tz="UTC", + ) + df = self._df(values_a=[0, 1, 2, 3], index=idx) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + gap_policy="mask", + max_gap_seconds=8.0, # gap = 9 seconds + fill_first="zero", + ).fit(df) + + out = tr.transform(df) + # increments: [0,1,1,1]; dt: [NaN,1,9,1]; mask where dt>5 -> index 2 + self.assertTrue(np.isnan(out["counter_a_diff"].iloc[2])) + self.assertEqual(out["counter_a_diff"].iloc[1], 1.0) + self.assertEqual(out["counter_a_diff"].iloc[3], 1.0) + + def test_gap_masking_with_factor_median(self) -> None: + """Threshold computed as factor * median(dt).""" + idx = pd.DatetimeIndex( + [ + self.t0, + self.t0 + timedelta(seconds=2), + self.t0 + timedelta(seconds=4), + self.t0 + timedelta(seconds=20), # gap 16 > factor*median (median=2) + ], + tz="UTC", + ) + df = self._df(values_a=[0, 2, 3, 5], index=idx) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + gap_policy="mask", + max_gap_seconds=None, + max_gap_factor=3.0, # 3 * median = 6 + fill_first="zero", + ).fit(df) + + out = tr.transform(df) + self.assertTrue(np.isnan(out["counter_a_diff"].iloc[3])) # masked at data gap + self.assertEqual(out["counter_a_diff"].iloc[1], 2.0) + self.assertEqual(out["counter_a_diff"].iloc[2], 1.0) + + def test_gap_policy_ignore(self) -> None: + """No masking when gap_policy='ignore'.""" + idx = pd.DatetimeIndex( + [self.t0, self.t0 + timedelta(seconds=1), self.t0 + timedelta(seconds=10)], + tz="UTC", + ) + df = self._df(values_a=[0, 1, 30], index=idx) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + gap_policy="ignore", + fill_first="zero", + ).fit(df) + + out = tr.transform(df) + expected = pd.Series([0, 1, 29], index=df.index, name="counter_a_diff") + pd.testing.assert_series_equal(out["counter_a_diff"], expected, check_dtype=False) + + def test_keep_original_false_drops_counters(self) -> None: + """When keep_original=False, original counters are dropped from output.""" + df = self._df(values_a=[0, 1, 2], values_b=[0, 10, 20]) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + keep_original=False, + gap_policy="ignore", + fill_first="zero", + ).fit(df) + + out = tr.transform(df) + # 'counter_b' should be kept, 'counter_a' replaced by 'counter_a_diff' + self.assertListEqual(list(out.columns), ["counter_b", "counter_a_diff"]) + + def test_keep_original_true_keeps_counters(self) -> None: + """When keep_original=True, original counters remain alongside outputs.""" + df = self._df(values_a=[0, 1, 2], values_b=[0, 10, 20]) + + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + keep_original=True, + gap_policy="ignore", + fill_first="zero", + ).fit(df) + + out = tr.transform(df) + self.assertListEqual(list(out.columns), ["counter_a", "counter_b", "counter_a_diff"]) + + def test_feature_names_out(self) -> None: + """get_feature_names_out returns correct output ordering.""" + df = self._df(values_a=[0, 1, 2], values_b=[0, 10, 20]) + tr = CounterDiffTransformer( + counters=["counter_a", "missing_counter"], + compute_rate=False, + keep_original=False, + gap_policy="ignore", + fill_first="zero", + ).fit(df) + + # Only present counters are transformed; others ignored + self.assertEqual(tr.counters_, ["counter_a"]) + self.assertEqual(tr.get_feature_names_out(), ["counter_b", "counter_a_diff"]) + + out = tr.transform(df) + self.assertListEqual(tr.get_feature_names_out(), list(out.columns)) + + def test_non_numeric_values_raise_error(self) -> None: + """Non-numeric values should be coerced to NaN then diff computed.""" + df = self._df(values_a=[0, "1", "3", "bad", 7]) # 'bad' -> NaN + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + gap_policy="ignore", + fill_first="zero", + ).fit(df) + + with self.assertRaises(ValueError): + tr.transform(df) + + def test_inverse_transform(self) -> None: + """inverse_transform returns input unchanged.""" + df = self._df(values_a=[0, 1, 2]) + tr = CounterDiffTransformer( + counters=["counter_a"], + compute_rate=False, + gap_policy="ignore", + fill_first="zero", + ).fit(df) + out = tr.transform(df) + back = tr.inverse_transform(out.copy()) + pd.testing.assert_frame_equal(out, back) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/data_preprocessing/test_data_clipper.py b/tests/data_preprocessing/test_data_clipper.py index dd9cd87..effefa9 100644 --- a/tests/data_preprocessing/test_data_clipper.py +++ b/tests/data_preprocessing/test_data_clipper.py @@ -2,6 +2,7 @@ import unittest import pandas as pd +import pandas.testing as pdt from energy_fault_detector.data_preprocessing.data_clipper import DataClipper @@ -29,3 +30,23 @@ def test_transform(self): ) self.data_clipper.fit(x_test) self.assertTrue(self.data_clipper.transform(x_test).equals(expected_output)) + + def test_transform_with_features_to_clip(self): + # Only clip 'feature1'; leave 'feature2' and angles unchanged + clipper = DataClipper(lower_percentile=0.2, upper_percentile=0.8, + features_to_clip=['feature1']) + x_test = pd.DataFrame( + {'feature1': [1, 2, 3, 4, 5], 'feature2': [4, 5, 6, 7, 8], 'angle1': [0, 45, 90, 135, 180], + 'angle2': [0, 45, 90, 135, 180]} + ) + expected_output = pd.DataFrame( + {'feature1': [1.8, 2, 3, 4, 4.2], 'feature2': [4, 5, 6, 7, 8], 'angle1': [0, 45, 90, 135, 180], + 'angle2': [0, 45, 90, 135, 180]} + ) + clipper.fit(x_test) + pdt.assert_frame_equal(clipper.transform(x_test), expected_output) + + def test_init_mutually_exclusive_args(self): + with self.assertRaises(ValueError): + DataClipper(lower_percentile=0.2, upper_percentile=0.8, + features_to_exclude=['angle1'], features_to_clip=['feature1']) diff --git a/tests/data_preprocessing/test_data_preprocessor.py b/tests/data_preprocessing/test_data_preprocessor.py index 5ebd235..2f33f6e 100644 --- a/tests/data_preprocessing/test_data_preprocessor.py +++ b/tests/data_preprocessing/test_data_preprocessor.py @@ -12,6 +12,17 @@ class TestDataPreprocessorPipeline(TestCase): def setUp(self) -> None: self.standard_preprocessor = DataPreprocessor( + steps=[ + {'name': 'column_selector', + 'params': {'max_nan_frac_per_col': 0.2}}, + {'name': 'angle_transform', + 'params': {'angles': ['Sensor_6']}}, + {'name': 'duplicate_values_to_nan'}, + {'name': 'low_unique_value_filter',} + ] + ) + # legacy set up + self.standard_preprocessor_old = DataPreprocessor( max_nan_frac_per_col=0.2, imputer_strategy='mean', angles=['Sensor_6'], @@ -20,17 +31,21 @@ def setUp(self) -> None: include_low_unique_value_filter=True, min_unique_value_count=2, ) - - self.ts_preprocessor = DataPreprocessor( - max_nan_frac_per_col=0.2, - min_unique_value_count=3, - max_col_zero_frac=0.30, - include_column_selector=True, - include_duplicate_value_to_nan=False, - include_low_unique_value_filter=True + self.another_preprocessor = DataPreprocessor( + steps=[ + {'name': 'column_selector', + 'params': {'max_nan_frac_per_col': 0.2}}, + {'name': 'angle_transform', + 'params': {'angles': ['Sensor_6']}}, + {'name': 'duplicate_values_to_nan', + 'params': {'n_max_duplicates': 4, + 'value_to_replace': 0}}, + {'name': 'low_unique_value_filter', + 'params': {'min_unique_value_count': 1}}, + ] ) - - self.extended_preprocessor = DataPreprocessor( + # legacy set up + self.another_preprocessor_old = DataPreprocessor( max_nan_frac_per_col=0.2, imputer_strategy='mean', min_unique_value_count=1, @@ -41,8 +56,16 @@ def setUp(self) -> None: include_duplicate_value_to_nan=True, include_low_unique_value_filter=True ) - + # Feature consistent, does not drop columns self.fc_preprocessor = DataPreprocessor( + steps=[ + {'name': 'column_selector', 'enabled': False}, + {'name': 'angle_transform', + 'params': {'angles': ['Sensor_6']}}, + ] + ) + # legacy set up + self.fc_preprocessor_old = DataPreprocessor( imputer_strategy='mean', angles=['Sensor_6'], include_low_unique_value_filter=False, @@ -94,12 +117,14 @@ def setUp(self) -> None: self.test_data3 = pd.DataFrame(index=time_index, data=data) def test_fit_standard_preprocessor(self): + self.standard_preprocessor_old.fit(self.test_data1) + check_is_fitted(self.standard_preprocessor_old.named_steps['scaler']) self.standard_preprocessor.fit(self.test_data1) check_is_fitted(self.standard_preprocessor.named_steps['scaler']) def test_fit_extended(self): - self.extended_preprocessor.fit(self.test_data3) - check_is_fitted(self.extended_preprocessor.named_steps['scaler']) + self.another_preprocessor_old.fit(self.test_data3) + check_is_fitted(self.another_preprocessor_old.named_steps['scaler']) def test_transform(self): # expected output @@ -139,8 +164,8 @@ def test_transform_extended(self): sincos = (sincos - sincos.mean(axis=0)) / sincos.std(axis=0) exp_result = np.hstack([exp_result, sincos]) - self.extended_preprocessor.fit(self.test_data3) - data = self.extended_preprocessor.transform(self.test_data3) + self.another_preprocessor_old.fit(self.test_data3) + data = self.another_preprocessor_old.transform(self.test_data3) assert_array_almost_equal(data, exp_result) @@ -156,8 +181,8 @@ def test_transform_fc(self): [1.21854359, 1.22474487, 0., 0., 0., 1.21773319, -1.32214018], [1.5666989, 1.63299316, 0., 0., 0., 1.56338116, -1.95410719]]) - self.fc_preprocessor.fit(self.test_data1) - data = self.fc_preprocessor.transform(self.test_data1) + self.fc_preprocessor_old.fit(self.test_data1) + data = self.fc_preprocessor_old.transform(self.test_data1) assert_array_almost_equal(data, exp_result) @@ -166,49 +191,134 @@ def test_not_fitted(self): self.standard_preprocessor.transform(self.test_data1) with self.assertRaises(NotFittedError): - self.ts_preprocessor.transform(self.test_data1) + self.another_preprocessor.transform(self.test_data1) def test_inverse_transform(self): - self.standard_preprocessor.fit(self.test_data1) + for preprocessor in [self.standard_preprocessor, self.standard_preprocessor_old]: + preprocessor.fit(self.test_data1) - output = self.standard_preprocessor.inverse_transform( - self.standard_preprocessor.transform(self.test_data1) - ).astype(float) - expected = self.test_data1[['Sensor_1', 'Sensor_2', 'Sensor_6']].astype(float) - expected.loc[pd.isnull(expected['Sensor_2']), 'Sensor_2'] = 5. + output = preprocessor.inverse_transform( + preprocessor.transform(self.test_data1) + ).astype(float) + expected = self.test_data1[['Sensor_1', 'Sensor_2', 'Sensor_6']].astype(float) + expected.loc[pd.isnull(expected['Sensor_2']), 'Sensor_2'] = 5. - assert_frame_equal( - output.reset_index(drop=True), - expected.reset_index(drop=True), - ) + assert_frame_equal( + output.reset_index(drop=True), + expected.reset_index(drop=True), + ) def test_inverse_transform_extended(self): - self.extended_preprocessor.fit(self.test_data3) - - output = self.extended_preprocessor.inverse_transform( - self.extended_preprocessor.transform(self.test_data3) - ).astype(float) - expected = self.test_data3[['Sensor_1', 'Sensor_2', 'Sensor_6', 'Sensor_7']].astype(float) - expected.loc[pd.isnull(expected['Sensor_2']), 'Sensor_2'] = 5. - expected.loc['2021-05-02 08:00:00', 'Sensor_7'] = 0.555556 - - assert_frame_equal( - output.reset_index(drop=True), - expected.reset_index(drop=True), - ) + for preprocessor in [self.another_preprocessor, self.another_preprocessor_old]: + preprocessor.fit(self.test_data3) + + output = preprocessor.inverse_transform( + preprocessor.transform(self.test_data3) + ).astype(float) + expected = self.test_data3[['Sensor_1', 'Sensor_2', 'Sensor_6', 'Sensor_7']].astype(float) + expected.loc[pd.isnull(expected['Sensor_2']), 'Sensor_2'] = 5. + expected.loc['2021-05-02 08:00:00', 'Sensor_7'] = 0.555556 + + assert_frame_equal( + output.reset_index(drop=True), + expected.reset_index(drop=True), + ) def test_inverse_transform_fc(self): - self.fc_preprocessor.fit(self.test_data1) - - output = self.fc_preprocessor.inverse_transform( - self.fc_preprocessor.transform(self.test_data1) - ).astype(float) - expected = self.test_data1.astype(float) - expected.loc[pd.isnull(expected['Sensor_2']), 'Sensor_2'] = 5. - expected.loc[pd.isnull(expected['Sensor_3']), 'Sensor_3'] = 2. - expected.loc[pd.isnull(expected['Sensor_4']), 'Sensor_4'] = 0. - - assert_frame_equal( - output.reset_index(drop=True), - expected.reset_index(drop=True), + for preprocessor in [self.fc_preprocessor, self.fc_preprocessor_old]: + preprocessor.fit(self.test_data1) + + output = preprocessor.inverse_transform( + preprocessor.transform(self.test_data1) + ).astype(float) + expected = self.test_data1.astype(float) + expected.loc[pd.isnull(expected['Sensor_2']), 'Sensor_2'] = 5. + expected.loc[pd.isnull(expected['Sensor_3']), 'Sensor_3'] = 2. + expected.loc[pd.isnull(expected['Sensor_4']), 'Sensor_4'] = 0. + + assert_frame_equal( + output.reset_index(drop=True), + expected.reset_index(drop=True), + ) + + def test_steps_mode_no_duplicate_imputer(self) -> None: + """Providing 'simple_imputer' explicitly should not add a second default imputer.""" + dp = DataPreprocessor( + steps=[ + {"name": "column_selector", "params": {"max_nan_frac_per_col": 0.2}}, + {"name": "simple_imputer", "params": {"strategy": "median"}}, + {"name": "standard_scaler"}, + ] + ) + # Count imputers by estimator type + n_imputers = sum( + est.__class__.__name__ == "SimpleImputer" for _, est in dp.steps + ) + self.assertEqual(n_imputers, 1, "There should be exactly one SimpleImputer.") + + # Ensure imputer precedes scaler + imputer_idx = next( + i for i, (_, est) in enumerate(dp.steps) if est.__class__.__name__ == "SimpleImputer" + ) + scaler_idx = next( + i for i, (_, est) in enumerate(dp.steps) + if est.__class__.__name__ in {"StandardScaler", "MinMaxScaler"} + ) + self.assertLess(imputer_idx, scaler_idx, "Imputer must precede scaler.") + + def test_steps_mode_default_imputer_inserted(self) -> None: + """Omitting 'simple_imputer' should auto-insert a default imputer before the scaler.""" + dp = DataPreprocessor( + steps=[ + {"name": "column_selector", "params": {"max_nan_frac_per_col": 0.2}}, + {"name": "standard_scaler"}, + ] + ) + # Exactly one imputer should be present + n_imputers = sum( + est.__class__.__name__ == "SimpleImputer" for _, est in dp.steps + ) + self.assertEqual(n_imputers, 1, "A single default SimpleImputer should be added.") + + # Imputer must be before scaler + imputer_idx = next( + i for i, (_, est) in enumerate(dp.steps) if est.__class__.__name__ == "SimpleImputer" + ) + scaler_idx = next( + i for i, (_, est) in enumerate(dp.steps) + if est.__class__.__name__ in {"StandardScaler", "MinMaxScaler"} + ) + self.assertLess(imputer_idx, scaler_idx, "Default imputer must be inserted before scaler.") + + def test_steps_mode_alias_imputer_is_normalized(self) -> None: + """Using 'imputer' alias should be normalized to 'simple_imputer' internally.""" + dp = DataPreprocessor( + steps=[ + {"name": "imputer", "params": {"strategy": "mean"}}, # alias + {"name": "standard_scaler"}, + ] ) + # Named steps should include the canonical 'simple_imputer' + self.assertIn("simple_imputer", dp.named_steps) + + def test_singleton_violation_raises(self) -> None: + """Two enabled simple_imputer steps should raise a ValueError.""" + with self.assertRaises(ValueError): + _ = DataPreprocessor( + steps=[ + {"name": "simple_imputer", "params": {"strategy": "mean"}}, + {"name": "simple_imputer", "params": {"strategy": "median"}}, + {"name": "standard_scaler"}, + ] + ) + + def test_only_one_scaler_allowed(self) -> None: + """Defining more than one scaler should raise a ValueError.""" + with self.assertRaises(ValueError): + _ = DataPreprocessor( + steps=[ + {"name": "column_selector", "params": {"max_nan_frac_per_col": 0.2}}, + {"name": "standard_scaler"}, + {"name": "minmax_scaler"}, + ] + ) diff --git a/tests/test_data/ensemble_config.yaml b/tests/test_data/ensemble_config.yaml deleted file mode 100644 index c610063..0000000 --- a/tests/test_data/ensemble_config.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Model settings -train: - anomaly_score: - name: 'mahalanobis' - params: - pca: true - pca_min_var: 0.85 - data_preprocessor: - name: 'default' - autoencoder: - name: 'EnsembleAutoencoder' - params: - hyperparams_list: - - layers: [300] - code_size: 50 - learning_rate: 0.001 - decay_rate: 0.001 - batch_size: 144 - epochs: 10 - loss_name: 'mean_squared_error' - - layers: [200] - code_size: 25 - learning_rate: 0.001 - decay_rate: 0.001 - batch_size: 144 - epochs: 20 - loss_name: 'mean_squared_error' - threshold_selector: - name: 'FDR' - params: - target_false_discovery_rate: 0.8 diff --git a/tests/test_data/test_bad_early_stopping_config.yaml b/tests/test_data/test_bad_early_stopping_config.yaml index 76c5908..f6e9284 100644 --- a/tests/test_data/test_bad_early_stopping_config.yaml +++ b/tests/test_data/test_bad_early_stopping_config.yaml @@ -5,13 +5,12 @@ train: pca: true pca_min_var: 0.85 data_preprocessor: - name: 'standard_preprocessor' - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 + steps: + - name: column_selector + params: + features_to_exclude: + - feature1 + - feature2 autoencoder: name: 'MultilayerAutoencoder' params: diff --git a/tests/test_data/test_conditional_ae_config.yaml b/tests/test_data/test_conditional_ae_config.yaml index f020a4a..66983fe 100644 --- a/tests/test_data/test_conditional_ae_config.yaml +++ b/tests/test_data/test_conditional_ae_config.yaml @@ -4,14 +4,7 @@ train: params: pca: true pca_min_var: 0.85 - data_preprocessor: - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 - include_duplicate_value_to_nan: false + data_preprocessor: # no further spec, so defaults are applied autoencoder: name: 'ConditionalAE' verbose: 0 diff --git a/tests/test_data/test_config.yaml b/tests/test_data/test_config.yaml index c6cf979..2f093ef 100644 --- a/tests/test_data/test_config.yaml +++ b/tests/test_data/test_config.yaml @@ -4,14 +4,7 @@ train: params: pca: true pca_min_var: 0.85 - data_preprocessor: - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 - include_duplicate_value_to_nan: false + data_preprocessor: # no further spec, so defaults are applied autoencoder: name: 'MultilayerAutoencoder' verbose: 0 diff --git a/tests/test_data/test_config_no_rca.yaml b/tests/test_data/test_config_no_rca.yaml index 7c83e0b..aa32fdc 100644 --- a/tests/test_data/test_config_no_rca.yaml +++ b/tests/test_data/test_config_no_rca.yaml @@ -5,13 +5,6 @@ train: pca: true pca_min_var: 0.85 data_preprocessor: - name: 'standard_preprocessor' - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 autoencoder: name: 'MultilayerAutoencoder' params: diff --git a/tests/test_data/test_criticality_config.yaml b/tests/test_data/test_criticality_config.yaml index 7165be0..f366d4c 100644 --- a/tests/test_data/test_criticality_config.yaml +++ b/tests/test_data/test_criticality_config.yaml @@ -5,13 +5,12 @@ train: pca: true pca_min_var: 0.85 data_preprocessor: - name: 'standard_preprocessor' - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 + steps: + - name: column_selector + params: + features_to_exclude: + - feature1 + - feature2 autoencoder: name: 'MultilayerAutoencoder' params: diff --git a/tests/test_data/test_early_stopping_val_block_config.yaml b/tests/test_data/test_early_stopping_val_block_config.yaml index 95ab325..62aad09 100644 --- a/tests/test_data/test_early_stopping_val_block_config.yaml +++ b/tests/test_data/test_early_stopping_val_block_config.yaml @@ -5,13 +5,12 @@ train: pca: true pca_min_var: 0.85 data_preprocessor: - name: 'standard_preprocessor' - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 + steps: + - name: column_selector + params: + features_to_exclude: + - feature1 + - feature2 autoencoder: name: 'MultilayerAutoencoder' params: diff --git a/tests/test_data/test_early_stopping_val_split_config.yaml b/tests/test_data/test_early_stopping_val_split_config.yaml index f1b256b..eb0d903 100644 --- a/tests/test_data/test_early_stopping_val_split_config.yaml +++ b/tests/test_data/test_early_stopping_val_split_config.yaml @@ -5,13 +5,12 @@ train: pca: true pca_min_var: 0.85 data_preprocessor: - name: 'standard_preprocessor' - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 + steps: + - name: column_selector + params: + features_to_exclude: + - feature1 + - feature2 autoencoder: name: 'MultilayerAutoencoder' params: diff --git a/tests/test_data/test_export_default_adaptive.yaml b/tests/test_data/test_export_default_adaptive.yaml deleted file mode 100644 index 77b0d8b..0000000 --- a/tests/test_data/test_export_default_adaptive.yaml +++ /dev/null @@ -1,42 +0,0 @@ -train: - data_clipping: # (optional) if not specified, not applied. - # clip training data to remove outliers - lower_percentile: 0.01 - upper_percentile: 0.99 - - data_preprocessor: - params: - features_to_exclude: - - a - - b - max_nan_frac_per_col: 0.2 - imputer_strategy: mean - min_unique_value_count: 1 - angles: - - c - - data_splitter: - # use train_test_split without shuffle for LSTM and CNN models! - type: sklearn - validation_split: 0.3 - shuffle: False - - autoencoder: - name: default - params: - batch_size: 8 - learning_rate: 0.001 - epochs: 10 - code_size: 1 - layers: - - 5 - loss_name: mean_squared_error - verbose: 0 - - anomaly_score: - name: mahalanobis - - threshold_selector: - name: adaptive - params: - gamma: 0.1 \ No newline at end of file diff --git a/tests/test_data/test_export_full_prep_cnn_ad.yaml b/tests/test_data/test_export_full_prep_cnn_ad.yaml deleted file mode 100644 index c62b556..0000000 --- a/tests/test_data/test_export_full_prep_cnn_ad.yaml +++ /dev/null @@ -1,53 +0,0 @@ -train: - data_clipping: # (optional) if not specified, not applied. - # clip training data to remove outliers - lower_percentile: 0.01 - upper_percentile: 0.99 - - data_preprocessor: - params: - features_to_exclude: - - a - - b - max_nan_frac_per_col: 0.2 - imputer_strategy: mean - min_unique_value_count: 1 - angles: - - c - n_max_duplicates: 4 - value_to_replace: 0 - include_column_selector: True - include_duplicate_value_to_nan: True - include_low_unique_value_filter: True - ts_features: - - 'day_of_year' - - 'hour_of_day' - verbose: 0 - - data_splitter: - # use train_test_split without shuffle for LSTM and CNN models! - type: sklearn - validation_split: 0.5 - shuffle: False - - autoencoder: - name: CNN - # Data sampler configuration (only for Seq2Seq models) - time_series_sampler: - sequence_length: 2 - overlap: 0 # ignored in random mode, used in inference phase - params: - batch_size: 8 - learning_rate: 0.001 - epochs: 1 - filters: - - 5 - loss_name: mean_squared_error - - anomaly_score: - name: rmse - - threshold_selector: - name: fbeta - params: - beta: 0.5 \ No newline at end of file diff --git a/tests/test_data/verbose_config.yaml b/tests/test_data/verbose_config.yaml index c9eaec9..bfebf23 100644 --- a/tests/test_data/verbose_config.yaml +++ b/tests/test_data/verbose_config.yaml @@ -5,13 +5,12 @@ train: pca: true pca_min_var: 0.85 data_preprocessor: - name: 'standard_preprocessor' - params: - max_nan_frac_per_col: 0.05 - imputer_strategy: 'mean' - features_to_exclude: - - feature1 - - feature2 + steps: + - name: column_selector + params: + features_to_exclude: + - feature1 + - feature2 autoencoder: name: 'MultilayerAutoencoder' params: