Source code for slickml.metrics._regression

from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
import scipy as scp
import seaborn as sns
from IPython.display import display
from matplotlib.figure import Figure
from sklearn.metrics import (
    explained_variance_score,
    mean_absolute_error,
    mean_absolute_percentage_error,
    mean_squared_error,
    mean_squared_log_error,
    r2_score,
)

from slickml.utils import check_var
from slickml.visualization import plot_regression_metrics


# TODO(amir): check the types here again
# TODO(amir): Examples should be added
# TODO(amir): more details should be added in docstring
# TODO(amir): look more into performance of the module; currently I think it takse a while
# my main hunch is the calculation of `REC` takes a while which can be dramatically optimized
# currently, it is implemented via `for loop`; vectorization can be helpul here
[docs]@dataclass class RegressionMetrics: """Regression Metrics is a wrapper to calculate all the regression metrics in one place. Notes ----- In case of multioutput regression, calculation methods can be chosen among ``"raw_values"``, ``"uniform_average"``, and ``"variance_weighted"``. Parameters ---------- y_true : Union[List[float], np.ndarray, pd.Series] Ground truth target (response) values y_pred : Union[List[float], np.ndarray, pd.Series] Predicted target (response) values multioutput : str, optional Method to calculate the metric for ``multioutput targets`` where possible values are ``"raw_values"``, ``"uniform_average"``, and ``"variance_weighted"``. ``"raw_values"`` returns a full set of scores in case of multioutput input. ``"uniform_average"`` scores of all outputs are averaged with uniform weight. ``"variance_weighted"`` scores of all outputs are averaged, weighted by the variances of each individual output, by default "uniform_average" precision_digits : int, optional The number of precision digits to format the scores dataframe, by default 3 display_df : bool, optional Whether to display the formatted scores' dataframe, by default True Methods ------- plot(figsize=(12, 16), save_path=None, display_plot=False, return_fig=False) Plots regression metrics get_metrics(dtype="dataframe") Returns calculated metrics Attributes ---------- y_residual_ : np.ndarray Residual values (errors) calculated as ``(y_true - y_pred)`` y_residual_normsq_ : np.ndarray Square root of absolute value of ``y_residual_`` r2_ : float :math:`R^2` score (coefficient of determination) with a possible value between 0.0 and 1.0 ev_ : float Explained variance score with a possible value between 0.0 and 1.0 mae_ : float Mean absolute error mse_ : float Mean squared error msle_ : float Mean squared log error mape_ : float Mean absolute percentage error auc_rec_ : float Area under REC curve with a possible value between 0.0 and 1.0 deviation_ : np.ndarray Arranged deviations to plot REC curve accuracy_ : np.ndarray Calculated accuracy at each deviation to plot REC curve y_ratio_ : np.ndarray Ratio of ``y_pred/y_true`` mean_y_ratio_ : float Mean value of ``y_pred/y_true`` ratio std_y_ratio_ : float Standard deviation value of ``y_pred/y_true`` ratio cv_y_ratio_ : float Coefficient of variation calculated as ``std_y_ratio/mean_y_ratio`` metrics_dict_ : Dict[str, Optional[float]] Rounded metrics based on the number of precision digits metrics_df_ : pd.DataFrame Pandas DataFrame of all calculated metrics plotting_dict_ : Dict[str, Any] Plotting properties References ---------- .. [Tahmassebi-et-al] Tahmassebi, A., Gandomi, A. H., & Meyer-Baese, A. (2018, July). A Pareto front based evolutionary model for airfoil self-noise prediction. In 2018 IEEE Congress on Evolutionary Computation (CEC) (pp. 1-8). IEEE. https://www.amirhessam.com/assets/pdf/projects/cec-airfoil2018.pdf .. [rec-curve] Bi, J., & Bennett, K. P. (2003). Regression error characteristic curves. In Proceedings of the 20th international conference on machine learning (ICML-03) (pp. 43-50). https://www.aaai.org/Papers/ICML/2003/ICML03-009.pdf Examples -------- >>> from slickml.metrics import RegressionMetrics >>> rm = RegressionMetrics( ... y_true=[3, -0.5, 2, 7], ... y_pred=[2.5, 0.0, 2, 8] ... ) >>> m = rm.get_metrics() >>> rm.plot() """ y_true: Union[List[float], np.ndarray, pd.Series] y_pred: Union[List[float], np.ndarray, pd.Series] multioutput: Optional[str] = "uniform_average" precision_digits: Optional[int] = 3 display_df: Optional[bool] = True
[docs] def __post_init__(self) -> None: """Post instantiation validations and assignments.""" check_var( self.y_true, var_name="y_true", dtypes=( np.ndarray, pd.Series, list, ), ) check_var( self.y_pred, var_name="y_pred", dtypes=( np.ndarray, pd.Series, list, ), ) check_var( self.multioutput, var_name="multioutput", dtypes=str, values=( "raw_values", "variance_weighted", "uniform_average", ), ) check_var( self.precision_digits, var_name="precision_digits", dtypes=int, ) check_var( self.display_df, var_name="display_df", dtypes=bool, ) # TODO(amir): add `list_to_array()` function into slickml.utils # TODO(amir): how numpy works with pd.Series here? kinda fuzzy if not isinstance(self.y_true, np.ndarray): self.y_true = np.array(self.y_true) if not isinstance(self.y_pred, np.ndarray): self.y_pred = np.array(self.y_pred) # TODO(amir): investigate the option of using @property instead of this for the whole API # TODO(amir): maybe adding `fit()` would make more sense ? self.y_residual_ = self.y_true - self.y_pred self.y_residual_normsq_ = np.sqrt(np.abs(self.y_residual_)) self.r2_ = self._r2() self.ev_ = self._ev() self.mae_ = self._mae() self.mse_ = self._mse() self.msle_ = self._msle() self.mape_ = self._mape() ( self.deviation_, self.accuracy_, self.auc_rec_, ) = self._rec_curve() ( self.y_ratio_, self.mean_y_ratio_, self.std_y_ratio_, self.cv_y_ratio_, ) = self._ratio_hist() self.metrics_dict_ = self._metrics_dict() self.metrics_df_ = self._metrics_df() self.plotting_dict_ = self._plotting_dict()
[docs] def plot( self, figsize: Optional[Tuple[float, float]] = (12, 16), save_path: Optional[str] = None, display_plot: Optional[bool] = False, return_fig: Optional[bool] = False, ) -> Optional[Figure]: """Plots regression metrics. Parameters ---------- figsize : Tuple[float, float], optional Figure size, by default (12, 16) save_path : str, optional The full or relative path to save the plot including the image format such as "myplot.png" or "../../myplot.pdf", by default None display_plot : bool, optional Whether to show the plot, by default False return_fig : bool, optional Whether to return figure object, by default False Returns ------- Figure, optional """ return plot_regression_metrics( figsize=figsize, save_path=save_path, display_plot=display_plot, return_fig=return_fig, **self.plotting_dict_, )
[docs] def get_metrics( self, dtype: Optional[str] = "dataframe", ) -> Union[pd.DataFrame, Dict[str, Optional[float]]]: """Returns calculated metrics with desired dtypes. Currently, available output types are ``"dataframe"`` and ``"dict"``. Parameters ---------- dtype : str, optional Results dtype, by default "dataframe" Returns ------- Union[pd.DataFrame, Dict[str, Optional[float]]] """ check_var( dtype, var_name="dtype", dtypes=str, values=("dataframe", "dict"), ) if dtype == "dataframe": return self.metrics_df_ else: return self.metrics_dict_
def _r2(self) -> float: """Calculates R^2 score. Returns ------- float """ return r2_score( y_true=self.y_true, y_pred=self.y_pred, multioutput=self.multioutput, ) def _ev(self) -> float: """Calculates explained variance score. Returns ------- float """ return explained_variance_score( y_true=self.y_true, y_pred=self.y_pred, multioutput=self.multioutput, ) def _mae(self) -> float: """Calculates mean-absolute-error. Returns ------- float """ return mean_absolute_error( y_true=self.y_true, y_pred=self.y_pred, multioutput=self.multioutput, ) def _mse(self) -> float: """Calculate mean-squared-error. Returns ------- float """ return mean_squared_error( y_true=self.y_true, y_pred=self.y_pred, multioutput=self.multioutput, ) # TODO(amir): double check the return type here with mypy def _msle(self) -> Optional[float]: """Calculates mean-squared-log-error. Returns ------- Optional[float] """ if min(self.y_true) < 0 or min(self.y_pred) < 0: msle = None else: msle = mean_squared_log_error( y_true=self.y_true, y_pred=self.y_pred, multioutput=self.multioutput, ) return msle def _mape(self) -> float: """Calculates mean-absolute-percentage-error. Returns ------- float """ return mean_absolute_percentage_error( y_true=self.y_true, y_pred=self.y_pred, multioutput=self.multioutput, ) # TODO(amir): how can vectorize the double for loop here ? def _rec_curve(self) -> Tuple[np.ndarray, np.ndarray, float]: """Calculates the rec curve elements: deviation, accuracy, auc. Notes ----- Simpson method is used as the integral method to calculate the area under regression error characteristics (REC) and the REC algorithm is implemented based on "Regression error characteristic curves" paper [rec-curve]_. Returns ------- Tuple[np.ndarray, np.ndarray, float] """ begin = 0.0 end = 1.0 interval = 0.01 accuracy = [] deviation = np.arange(begin, end, interval) # TODO(amir): this would prolly break mypy since it cannot understand that the list is alrady cast to # np.ndarray; so np.array() or np.linalg.norm() should be used norms = np.abs(self.y_true - self.y_pred) / np.sqrt( # type: ignore self.y_true**2 + self.y_pred**2, # type: ignore ) # main loop to count the number of times that the calculated norm is less than deviation for _, dev in enumerate(deviation): count = 0.0 for _, norm in enumerate(norms): if norm < dev: count += 1 accuracy.append(count / len(self.y_true)) auc_rec = scp.integrate.simps(accuracy, deviation) / end return (deviation, np.array(accuracy), auc_rec) def _ratio_hist(self) -> Tuple[np.ndarray, float, float, float]: """Calculates the histogram elements of y_pred/y_true ratio. This would report the coefficient of variation CV as std(ratio)/mean(ratio) based [Tahmassebi-et-al]_. Returns ------- Tuple[np.ndarray, float, float, float] """ # TODO(amir): self.y_pred is already np.ndarray and mypy does not infer it y_ratio = self.y_pred / self.y_true # type: ignore mean_y_ratio = np.mean(y_ratio) std_y_ratio = np.std(y_ratio) cv_y_ratio = std_y_ratio / mean_y_ratio return (y_ratio, mean_y_ratio, std_y_ratio, cv_y_ratio) # TODO(amir): refactor this into a dataclass with dependency injection def _metrics_dict(self) -> Dict[str, Optional[float]]: """Rounded calculated metrics based on the number of precision digits. Returns ------- Dict[str, Optional[float]] """ return { "R2 Score": round( number=self.r2_, ndigits=self.precision_digits, ), "Explained Variance Score": round( number=self.ev_, ndigits=self.precision_digits, ), "Mean Absolute Error": round( number=self.mae_, ndigits=self.precision_digits, ), "Mean Squared Error": round( number=self.mse_, ndigits=self.precision_digits, ), "Mean Squared Log Error": round( number=self.msle_, ndigits=self.precision_digits, ) if self.msle_ else None, "Mean Absolute Percentage Error": round( number=self.mape_, ndigits=self.precision_digits, ), "REC AUC": round( number=self.auc_rec_, ndigits=self.precision_digits, ), "Coeff. of Variation": round( number=self.cv_y_ratio_, ndigits=self.precision_digits, ), "Mean of Variation": round( number=self.mean_y_ratio_, ndigits=self.precision_digits, ), } def _metrics_df(self) -> pd.DataFrame: """Creates a pandas DataFrame of all calculated metrics with custom formatting. The resulted dataframe contains all the metrics based on the precision digits and selected average method. Returns ------- pd.DataFrame """ metrics_df = pd.DataFrame( data=self.metrics_dict_, index=["Metrics"], ) # TODO(amir): can we do df.reindex() ? metrics_df = metrics_df.reindex( columns=[ "R2 Score", "Explained Variance Score", "Mean Absolute Error", "Mean Squared Error", "Mean Squared Log Error", "Mean Absolute Percentage Error", "REC AUC", "Coeff. of Variation", "Mean of Variation", ], ) # TODO(amir): move this to a utility function under utils/format.py since it is repeated # that would make it more general and scalable across API # Set CSS properties th_props = [ ("font-size", "12px"), ("text-align", "left"), ("font-weight", "bold"), ] td_props = [ ("font-size", "12px"), ("text-align", "center"), ] # Set table styles styles = [ dict( selector="th", props=th_props, ), dict( selector="td", props=td_props, ), ] cm = sns.light_palette( "blue", as_cmap=True, ) if self.display_df: display( metrics_df.style.background_gradient( cmap=cm, ).set_table_styles(styles), ) return metrics_df # TODO(amir): think of a dataclass with the ability of returning all data entries as a dict # this can be used along with dependency injection design pattern def _plotting_dict(self) -> Dict[str, Any]: """Returns the plotting properties. Returns ------- Dict[str, Any] """ return { "r2": self.r2_, "ev": self.ev_, "mae": self.mae_, "mse": self.mse_, "y_pred": self.y_pred, "y_true": self.y_true, "y_residual": self.y_residual_, "y_residual_normsq": self.y_residual_normsq_, "auc_rec": self.auc_rec_, "y_ratio": self.y_ratio_, "cv_y_ratio": self.cv_y_ratio_, "std_y_ratio": self.std_y_ratio_, "mean_y_ratio": self.mean_y_ratio_, "msle": self.msle_, "mape": self.mape_, "deviation": self.deviation_, "accuracy": self.accuracy_, }