SIENTIAPDE-1222

SIENTIAPDE-1214: Enhance MLFlow and tests with datetime index handling and logging improvements

- Added a new method in MLFlow to detect and parse datetime indices in DataFrames, ensuring proper format and raising errors for invalid types.
- Updated prediction workflows to utilize the new datetime index handling, improving data integrity during transformations.
- Enhanced logging in model_repository to include detailed data outputs for better traceability.
- Adjusted timeout settings in prediction workflows for improved execution time management.
- Updated tests.ipynb to include additional checks for index types and outputs for better validation of functionality.
This commit is contained in:
vitor-aignosi
2025-09-15 14:47:24 -03:00
parent caca923717
commit 934298b3c3
9 changed files with 144 additions and 829 deletions

View File

@@ -1,824 +0,0 @@
"""
Base model classes and interfaces.
This module defines base classes with consistent interfaces for all models,
promoting modular model development. It includes essential functionality
for model fitting, prediction, evaluation, saving, and loading, while
abstracting common behaviors into base classes.
While full compatibility with scikit-learn is not guaranteed, the base
classes provide a consistent interface for model fitting, prediction,
evaluation, saving, and loading, which should be sufficient for most
use cases.
Key components:
- **Model**: Abstract base class for all models, providing core utilities and
interfaces.
- **TimeSeriesModel**: Abstract base class for time series models, adding
time-based functionality.
- **UnivariateTimeSeriesModel**: Base class for univariate time series models.
- **MultivariateTimeSeriesModel**: Base class for multivariate time series
models that use exogenous features.
The module also includes utility functions such as `ensure_fitted`, which
ensures models are fitted before calling certain methods.
Modules in this package should inherit from these base classes and implement
the required methods.
TODO:
- Add methods to create lagged features for time series models.
"""
import uuid
import joblib
from abc import ABC, abstractmethod
from typing import Optional, List, Sequence, Tuple, cast, Any, Protocol
from contextlib import contextmanager
import pandas as pd
import numpy as np
import shap
import matplotlib.pyplot as plt
from rich.console import Console
from sklearn.base import BaseEstimator, RegressorMixin
from sklearn.utils.validation import check_array, check_X_y
from sklearn.exceptions import NotFittedError
console = Console()
class PredictorProtocol(Protocol):
"""Protocol for models with predict method and optional imputation."""
def predict(self, X: Any) -> Any: ...
def _impute_missing_values(self, X: Any) -> Any: ...
def ensure_fitted(method):
"""
Decorator to ensure the model is fitted before calling the method.
Raises:
sklearn.exceptions.NotFittedError: If the model is not fitted.
Usage:
@ensure_fitted
def predict(self, X): # Or other methods requiring fit
pass
"""
def wrapper(self, *args, **kwargs):
is_fitted = self.__sklearn_is_fitted__()
if not is_fitted:
raise NotFittedError(
f"This {self.__class__.__name__} instance is not fitted yet. "
"Call 'fit' with appropriate arguments before using this "
"method."
)
return method(self, *args, **kwargs)
return wrapper
class Model(BaseEstimator, ABC):
"""
Abstract base class for all models.
Provides core utilities, input validation, and interface consistency
for time series models. Compatible with scikit-learn workflows.
"""
def __init__(self, name: Optional[str] = None, random_seed: int = 42):
"""
Initialize the model with a unique name and random seed.
Args:
name: Optional identifier; auto-generated if None.
random_seed: Seed for reproducibility.
"""
self.name = name or f"{self.__class__.__name__}_{uuid.uuid4().hex}"
self.random_seed = random_seed
self.feature_names_in_: Optional[List[str]] = None
self.n_features_in_: Optional[int] = None
self._is_fitted = False
def fit(
self,
y: pd.Series,
X: Optional[pd.DataFrame] = None,
X_val: Optional[pd.DataFrame] = None,
y_val: Optional[pd.Series] = None,
) -> "Model":
"""
Trains the model.
Handles basic input validation for y and sets internal fitted
state after calling _fit_logic.
'X' is optional to account for univariate time series models.
Args:
y: The target variable.
X: Optional exogenous variables.
X_val: Optional validation feature matrix.
y_val: Optional validation target series.
Raises:
TypeError: If y is not a pandas Series.
If X is provided, it must be a pandas DataFrame.
If X_val and y_val are provided, they must be pandas DataFrames
and Series respectively.
Returns:
Self for chaining.
"""
if not isinstance(y, pd.Series):
raise TypeError("Input 'y' (target) must be a pandas Series.")
self._fit_logic(y, X, X_val, y_val)
self._is_fitted = True
return self
@abstractmethod
def _fit_logic(
self,
y: pd.Series,
X: Optional[pd.DataFrame] = None,
X_val: Optional[pd.DataFrame] = None,
y_val: Optional[pd.Series] = None,
) -> None:
"""
Core fitting logic to be implemented by subclasses with
optional validation data.
Args:
y: The target variable.
X: Optional exogenous variables.
X_val: Validation feature matrix (optional).
y_val: Validation target series (optional).
"""
raise NotImplementedError("Subclasses must implement _fit_logic().")
@ensure_fitted
@abstractmethod
def predict(self, X: Optional[pd.DataFrame] = None) -> Sequence:
"""
Predict values.
Args:
X: Optional features for prediction. For univariate models
not using exogenous variables, this might be None or
contain future timestamps. Multivariate models will
require X.
Returns:
NumPy array or similar sequence of predictions.
"""
raise NotImplementedError("Subclasses must implement predict().")
def fit_predict(
self,
y: pd.Series,
X: Optional[pd.DataFrame] = None,
X_val: Optional[pd.DataFrame] = None,
y_val: Optional[pd.Series] = None,
) -> Sequence:
"""
Fits model and returns predictions on the same data.
Args:
y: The target time series.
X: Optional exogenous variables.
Returns:
Predictions for the input data.
"""
return self.fit(y, X, X_val, y_val).predict(X)
def save(self, path: str) -> None:
"""
Saves model to disk using joblib.
"""
joblib.dump(self, path)
@classmethod
def load(cls, path: str) -> "Model":
"""
Loads model from disk using joblib.
"""
return joblib.load(path)
def __str__(self) -> str:
return f"{self.__class__.__name__}(name={self.name})"
def __sklearn_is_fitted__(self) -> bool:
"""
Check fitted status and return a Boolean value.
"""
return hasattr(self, "_is_fitted") and self._is_fitted
def __repr__(self) -> str:
return f"{self.__class__.__name__}(name={self.name})"
@contextmanager
def model_state_preservation(self):
"""Context manager to preserve model state during operations."""
original_state = self._get_state_snapshot()
try:
yield
except Exception:
self._restore_state_snapshot(original_state)
raise
def _get_state_snapshot(self) -> dict:
"""Get snapshot of current model state."""
return {
"name": self.name,
"is_fitted": getattr(self, "_is_fitted", False),
"feature_names": self.feature_names_in_,
"n_features": self.n_features_in_,
}
def _restore_state_snapshot(self, snapshot: dict) -> None:
"""Restore model state from snapshot."""
self.name = snapshot["name"]
self._is_fitted = snapshot["is_fitted"]
self.feature_names_in_ = snapshot["feature_names"]
self.n_features_in_ = snapshot["n_features"]
def get_params_dict(self) -> dict:
"""Get model parameters as dictionary for logging/serialization."""
return {
"name": self.name,
"random_seed": self.random_seed,
"n_features_in_": self.n_features_in_,
}
def summary(self) -> str:
"""Generate a summary string of the model."""
params = self.get_params_dict()
fitted_status = (
"✓ Fitted" if self.__sklearn_is_fitted__() else "✗ Not fitted"
)
summary_lines = [
f"Model: {self.__class__.__name__}",
f"Status: {fitted_status}",
f"Features: {params.get('n_features_in_', 'Unknown')}",
]
return "\n".join(summary_lines)
class TimeSeriesModel(Model):
"""
Abstract base class for time series forecasting models.
Extends the base Model class with specific methods for time series
data handling and evaluation.
"""
def __init__(
self,
name: Optional[str] = None,
time_col: str = "ds",
target_col: str = "y",
random_seed: int = 42,
n_lags: int = 0,
sampling_freq: Optional[str] = None,
):
super().__init__(name=name, random_seed=random_seed)
self.time_col = time_col
self.target_col = target_col
self.n_lags = n_lags
self.sampling_freq = sampling_freq
self.training_series_: Optional[pd.Series] = None
self.model_: Optional[BaseEstimator] = None
# Validate configuration
self._validate_configuration()
def _validate_configuration(self) -> None:
"""Validate model configuration."""
if self.n_lags < 0:
raise ValueError("n_lags must be non-negative")
def _validate_y(self, y: pd.Series) -> np.ndarray:
"""
Validates the target variable (y) for the model.
Ensures y is a pandas Series and checks its name against
the expected target column name. Converts y to a NumPy array.
The series name can be None, but if it is set, it should match
the expected target column name.
Args:
y: The target variable as a pandas Series.
Returns:
A NumPy array of the target variable.
Raises:
TypeError: If y is not a pandas Series.
"""
# Check if y is a pandas Series
if not isinstance(y, pd.Series):
raise TypeError("Input 'y' (target) must be a pandas Series.")
if (y.name is not None) and (y.name != self.target_col):
raise ValueError(
f"Expected target column name '{self.target_col}', "
f"but got '{y.name}'."
)
return check_array(y, ensure_2d=False)
@ensure_fitted
@abstractmethod
def backtest(
self,
y: pd.Series,
X: Optional[pd.DataFrame] = None,
retrain_every: int = 50,
reuse_previous_execution: bool = False,
) -> pd.Series:
"""
Performs backtesting on the time series data.
Args:
y: The target time series data.
X: Optional exogenous features.
retrain_every: Number of steps after which to retrain the model.
reuse_previous_execution: Whether to reuse the previous execution
of a backtest. If True, any overlapping data between the
previous execution and the current execution will be used
without retraining the model.
Returns:
Series of predictions for each step in the time series.
"""
raise NotImplementedError("Subclasses must implement backtest().")
def get_params_dict(self) -> dict:
"""Get model parameters as dictionary for logging/serialization."""
base_params = super().get_params_dict()
ts_params = {
"time_col": self.time_col,
"target_col": self.target_col,
"n_lags": self.n_lags,
"sampling_freq": self.sampling_freq,
}
return {**base_params, **ts_params}
class UnivariateTimeSeriesModel(TimeSeriesModel, RegressorMixin):
"""
Base class for univariate time series models.
Only supports regression settings. Concrete subclasses
must implement `_fit_logic` and `predict`.
"""
@abstractmethod
@ensure_fitted
def forecast(self, forecast_horizon: int) -> Sequence:
"""
Forecast into the future for a given number of steps.
Args:
forecast_horizon: Number of future time steps to forecast.
Returns:
Sequence of forecasted values.
"""
raise NotImplementedError("Subclasses must implement forecast().")
class MultivariateTimeSeriesModel(TimeSeriesModel):
"""
Base class for multivariate time series models.
This class provides a foundation for time series models that utilize
multiple exogenous features (X) to predict a target variable (y).
It supports both regression and classification tasks.
Attributes:
selected_features_: List of feature names selected for the model.
learning_task: Type of learning task ('regression', 'binary',
'multiclass').
differentiate_target: Whether to apply differencing to make series
stationary.
bins: Bin edges for multiclass classification target
transformation.
Example:
>>> class MyModel(MultivariateTimeSeriesModel):
... def _fit_logic(self, y, X=None, **kwargs):
... # Implementation here
... pass
... def predict(self, X=None):
... # Implementation here
... return predictions
"""
def __init__(
self,
name: Optional[str] = None,
time_col: str = "ds",
target_col: str = "y",
random_seed: int = 42,
n_lags: int = 0,
sampling_freq: Optional[str] = None,
differentiate_target: bool = False,
bins: Optional[List[float]] = None,
learning_task: Optional[str] = None,
):
# Set attributes before calling parent constructor
# This is needed because parent constructor calls
# _validate_configuration
self.selected_features_: Optional[List[str]] = None
self.learning_task: Optional[str] = learning_task
self.differentiate_target = differentiate_target
self.bins = bins
self.model_: Optional[PredictorProtocol] = None
super().__init__(
name=name,
time_col=time_col,
target_col=target_col,
random_seed=random_seed,
n_lags=n_lags,
sampling_freq=sampling_freq,
)
# Additional validation for multivariate models
self._validate_learning_task()
def _validate_learning_task(self) -> None:
"""Validate learning task configuration."""
valid_tasks = {"regression", "binary", "multiclass", None}
if self.learning_task not in valid_tasks:
raise ValueError(
f"Invalid learning_task: {self.learning_task}. "
+ f"Must be one of {valid_tasks}"
)
if self.learning_task == "multiclass" and not self.bins:
raise ValueError(
"bins must be provided for multiclass learning_task"
)
def _get_default_loss_function(
self, provided_loss: Optional[str]
) -> str:
"""
Get default loss function based on learning task.
Args:
provided_loss: User-provided loss function (takes precedence)
Returns:
str: Appropriate loss function for the learning task
"""
if provided_loss is not None:
return provided_loss
if self.learning_task == "regression":
return "RMSE"
elif self.learning_task == "binary":
return "Logloss"
elif self.learning_task == "multiclass":
return "MultiClass"
else:
return "RMSE"
def _validate_configuration(self) -> None:
"""Validate model configuration."""
super()._validate_configuration()
if self.differentiate_target and self.learning_task in [
"binary",
"multiclass",
]:
console.print(
"[yellow]Warning: Using differentiation with classification "
+ "tasks may not be appropriate[/yellow]"
)
@ensure_fitted
def feature_importance(self) -> Optional[pd.DataFrame]:
"""
Returns feature importance if implemented by subclass.
Returns:
A DataFrame with feature names and their importance scores,
or None if not applicable.
"""
return None
def _validate_X_y(
self, X: pd.DataFrame, y: pd.Series, allow_nan: bool = True
) -> Tuple[np.ndarray, np.ndarray]:
"""
Validates input features (X) and target (y).
Infers and sets `feature_names_in_` and `n_features_in_`.
This method should be called within the `_fit_logic` of
concrete subclasses that use exogenous features.
Args:
X: DataFrame of input features.
y: Series for the target variable.
allow_nan: If True, allows NaN values in X and y.
Raises:
TypeError: If X is not a DataFrame or y is not a Series.
ValueError: If the number of features in X does not match
the expected number of features.
Returns:
Tuple of validated NumPy arrays (X_array, y_array).
"""
if allow_nan:
X_array, y_array = check_X_y(X, y, force_all_finite=False)
else:
X_array, y_array = check_X_y(X, y, force_all_finite=True)
if hasattr(X, "columns"):
console.log(
f"Validating input features with columns: {X.columns.tolist()}"
)
self.feature_names_in_ = list(X.columns)
else:
console.log(
"Input features do not have column names, using default names."
)
self.feature_names_in_ = [
f"feature_{i}" for i in range(X_array.shape[1])
]
self.n_features_in_ = X_array.shape[1]
return X_array, y_array
def _validate_X(
self, X: pd.DataFrame, allow_nan: bool = True
) -> np.ndarray:
"""
Validates input features (X) before prediction or scoring.
Ensures consistency with features seen during fit. This should
be called by concrete subclasses in `predict`, `score`, etc.
Args:
X: DataFrame of input features.
allow_nan: If True, allows NaN values in X.
Returns:
Validated NumPy array of X.
"""
if allow_nan:
X_array = check_array(X, force_all_finite=False)
else:
X_array = check_array(X, force_all_finite=True)
# If the model has been fitted, ensure the input features
# match the features seen during fit.
if self.feature_names_in_ is not None:
if not set(self.feature_names_in_).issubset(X.columns):
raise ValueError(
"Input features do not match the features seen during fit."
+ f" Expected features: {self.feature_names_in_}, "
+ f"but got: {list(X.columns)}."
)
return X_array
def _transform_target_to_multiclass(
self, y: pd.Series, bins: Optional[List[float]] = None
) -> pd.Series:
"""
Transforms the target variable into a multiclass classification
target.
If bins are provided, uses pd.cut to categorize the target into
discrete classes. If not, binarize the target at zero (0).
Args:
y: The target variable as a pandas Series.
bins: Optional list of bin edges for categorization.
Returns:
A pandas Series with transformed classification targets.
"""
if bins is not None:
# pd.cut returns a Categorical, convert to Series with integer
# codes
categories = pd.cut(y, bins=bins, labels=False)
return pd.Series(categories, index=y.index)
return (y > 0).astype(int)
def _transform_target_to_binary(
self, y: pd.Series, threshold: float = 0.0
) -> pd.Series:
"""
Transforms the target variable into a binary classification target.
Binarizes the target at the specified threshold (default is 0.0).
Args:
y: The target variable as a pandas Series.
threshold: The threshold for binarization.
Returns:
A pandas Series with binary classification targets.
"""
return (y > threshold).astype(int)
def _preprocess_data(
self,
y: pd.Series,
X: Optional[pd.DataFrame] = None,
X_val: Optional[pd.DataFrame] = None,
y_val: Optional[pd.Series] = None,
) -> Tuple[
pd.Series,
Optional[pd.DataFrame],
Optional[pd.Series],
Optional[pd.DataFrame],
]:
"""
Internal method to handle common data preprocessing operations.
Args:
y: The target time series data
X: The feature matrix (including exogenous features)
X_val: Validation feature matrix (optional)
y_val: Validation target series (optional)
Returns:
A tuple containing:
- processed y series
- processed X dataframe (optional)
- processed y_val series (optional)
- processed X_val dataframe (optional)
"""
# Apply differentiation if enabled
if self.differentiate_target:
y = y.diff().dropna()
if X is not None:
X = X.loc[y.index]
# Transform target for classification if needed
if self.learning_task == "binary":
y = self._transform_target_to_binary(y)
elif self.learning_task == "multiclass":
y = self._transform_target_to_multiclass(y, self.bins)
# Process validation data if provided
if y_val is not None:
if X_val is None:
raise ValueError(
"Validation features (X_val) must be provided if "
+ "validation target (y_val) is given."
)
y_val = y_val.loc[X_val.index]
if self.differentiate_target:
y_val = y_val.diff().dropna()
X_val = X_val.loc[y_val.index]
if self.learning_task == "binary":
y_val = self._transform_target_to_binary(y_val)
elif self.learning_task == "multiclass":
y_val = self._transform_target_to_multiclass(y_val, self.bins)
# Filter features if selected_features_ is set
if X is not None and self.selected_features_ is not None:
X = cast(pd.DataFrame, X[self.selected_features_].copy())
if X_val is not None:
X_val = cast(
pd.DataFrame, X_val[self.selected_features_].copy()
)
return y, X, y_val, X_val
def _prepare_shap_data(self, X: pd.DataFrame) -> pd.DataFrame:
"""Prepare data for SHAP analysis."""
X_processed = X.copy()
# Remove target column if present
if self.target_col in X_processed.columns:
X_processed = X_processed.drop(columns=[self.target_col])
# Filter selected features
if self.selected_features_ is not None:
X_processed = cast(
pd.DataFrame, X_processed[self.selected_features_].copy()
)
return X_processed
def _create_shap_explainer(self, X: pd.DataFrame) -> Any:
"""Create appropriate SHAP explainer based on model type."""
if self.model_ is None:
raise ValueError("Model is not fitted yet.")
if hasattr(self.model_, "coef_"): # Linear models
try:
# Handle missing values if model supports it
X_clean = self._handle_missing_values_for_shap(X)
return shap.LinearExplainer(self.model_, X_clean)
except Exception as e:
console.print(
f"[yellow]Warning: Linear explainer failed: {e}, "
+ "using KernelExplainer[/yellow]"
)
background = shap.maskers.Independent(X, max_samples=100)
return shap.KernelExplainer(
self.model_.predict,
background,
)
else:
# Non-linear models
if self.learning_task == "binary":
return shap.TreeExplainer(
self.model_, X, model_output="probability"
)
else:
return shap.Explainer(self.model_, X)
def _handle_missing_values_for_shap(self, X: pd.DataFrame) -> pd.DataFrame:
"""Handle missing values for SHAP analysis."""
# Use type ignore for optional method
if hasattr(self.model_, "_impute_missing_values"):
return self.model_._impute_missing_values(X) # type: ignore
else:
return X.dropna()
def _generate_and_save_plot(
self, explainer: Any, X: pd.DataFrame, path: str
) -> None:
"""Generate and save SHAP plot."""
shap_values = explainer(X)
shap.plots.beeswarm(shap_values, show=False)
shap_fig = plt.gcf()
shap_fig.set_size_inches(10, 6)
shap_fig.suptitle(f"SHAP Beeswarm Plot for {self.name}", fontsize=16)
shap_fig.tight_layout()
shap_fig.savefig(path)
plt.clf()
plt.close()
@ensure_fitted
def shap_beeswarm_plot(self, X: pd.DataFrame, path: str) -> None:
"""
Generates a SHAP beeswarm plot for the model's predictions.
Args:
X: DataFrame of input features.
path: Path to save the plot file.
Raises:
ValueError: If model is not fitted.
Exception: If SHAP plot generation fails.
"""
if self.model_ is None:
raise ValueError("Model is not fitted yet.")
try:
# Prepare data
X_processed = self._prepare_shap_data(X)
# Create explainer and generate plot
explainer = self._create_shap_explainer(X_processed)
self._generate_and_save_plot(explainer, X_processed, path)
except Exception as e:
console.print(
f"[red]Error: Failed to generate SHAP plot: {e}[/red]"
)
raise
def get_params_dict(self) -> dict:
"""Get model parameters as dictionary for logging/serialization."""
base_params = super().get_params_dict()
mv_params = {
"learning_task": self.learning_task,
"differentiate_target": self.differentiate_target,
"selected_features_": self.selected_features_,
}
return {**base_params, **mv_params}
def summary(self) -> str:
"""Generate a summary string of the model."""
params = self.get_params_dict()
fitted_status = (
"✓ Fitted" if self.__sklearn_is_fitted__() else "✗ Not fitted"
)
summary_lines = [
f"Model: {self.__class__.__name__}",
f"Status: {fitted_status}",
f"Features: {params.get('n_features_in_', 'Unknown')}",
f"Task: {params.get('learning_task', 'regression')}",
f"Selected Features: {len(self.selected_features_) if self.selected_features_ else 'All'}",
]
return "\n".join(summary_lines)