"""Metrics for evaluating forecasts."""
import logging
from typing import Any
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
[docs]
def mae(y_true: Any, y_pred: Any) -> float:
"""Mean Absolute Error.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
Mean absolute error.
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
return float(np.mean(np.abs(y_true - y_pred)))
[docs]
def rmse(y_true: Any, y_pred: Any) -> float:
"""Root Mean Squared Error.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
Root mean squared error.
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
[docs]
def mape(y_true: Any, y_pred: Any) -> float:
"""Mean Absolute Percentage Error with safe zero handling.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
Mean absolute percentage error. Returns NaN if all true values are zero.
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
# Handle zeros: only compute MAPE where y_true != 0
mask = y_true != 0
if not np.any(mask):
return float(np.nan)
percentage_errors = np.abs((y_true[mask] - y_pred[mask]) / y_true[mask]) * 100
return float(np.mean(percentage_errors))
[docs]
def bias(y_true: Any, y_pred: Any) -> float:
"""Calculate bias (mean error).
Bias measures the average difference between predicted and actual values.
Positive bias indicates over-estimation, negative indicates under-estimation.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
Bias value.
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
# Remove NaN and infinite values
mask = ~(np.isnan(y_true) | np.isnan(y_pred) | np.isinf(y_true) | np.isinf(y_pred))
y_true = y_true[mask]
y_pred = y_pred[mask]
if len(y_true) == 0:
return float(np.nan)
return float(np.mean(y_pred - y_true))
[docs]
def ubrmse(y_true: Any, y_pred: Any) -> float:
"""Calculate Unbiased Root Mean Square Error (ubRMSE).
ubRMSE removes the impact of bias from RMSE, measuring only the random
component of error. Useful when assessing precision separately from accuracy.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
ubRMSE value.
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
# Remove NaN and infinite values
mask = ~(np.isnan(y_true) | np.isnan(y_pred) | np.isinf(y_true) | np.isinf(y_pred))
y_true = y_true[mask]
y_pred = y_pred[mask]
if len(y_true) == 0:
return float(np.nan)
# Calculate bias
bias_val = np.mean(y_pred - y_true)
# Remove bias from predictions
y_pred_unbiased = y_pred - bias_val
# Calculate RMSE of unbiased predictions
return float(np.sqrt(np.mean((y_true - y_pred_unbiased) ** 2)))
[docs]
def smape(y_true: Any, y_pred: Any) -> float:
"""Symmetric Mean Absolute Percentage Error.
SMAPE is symmetric and handles zero values better than MAPE.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
SMAPE value (percentage).
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
numerator = np.abs(y_pred - y_true)
denominator = (np.abs(y_true) + np.abs(y_pred)) / 2
# Handle division by zero
mask = denominator > 0
if not np.any(mask):
return float(np.nan)
return float(np.mean(numerator[mask] / denominator[mask]) * 100)
[docs]
def r2_score(y_true: Any, y_pred: Any) -> float:
"""R-squared coefficient of determination.
Args:
y_true: True values.
y_pred: Predicted values.
Returns:
R² score.
"""
y_true = _to_array(y_true)
y_pred = _to_array(y_pred)
if len(y_true) != len(y_pred):
raise ValueError(
f"y_true and y_pred must have same length. "
f"Got {len(y_true)} and {len(y_pred)}"
)
ss_res = np.sum((y_true - y_pred) ** 2)
ss_tot = np.sum((y_true - np.mean(y_true)) ** 2)
# Handle constant values case where ss_tot = 0
if ss_tot == 0:
# If actual values are constant and predictions match, R² = 1
if ss_res == 0:
return 1.0
# If actual values are constant but predictions don't match, R² = 0
else:
return 0.0
return float(1 - (ss_res / ss_tot))
def _to_array(data: Any) -> np.ndarray:
"""Convert data to numpy array.
Args:
data: Data to convert (Series, DataFrame, array, etc.).
Returns:
Numpy array.
"""
if isinstance(data, pd.Series):
return data.values
elif isinstance(data, pd.DataFrame):
if data.shape[1] == 1:
return data.iloc[:, 0].values
else:
raise ValueError("DataFrame must have single column for metrics")
elif isinstance(data, np.ndarray):
return data
else:
return np.array(data)