Source code for empulse.metrics.metric.metric

from collections.abc import Callable
from numbers import Real

import numpy as np
import sympy

from ..._types import FloatArrayLike, FloatNDArray
from .common import Direction, _evaluate_expression
from .cost_matrix import CostMatrix
from .strategies import MetricStrategy


[docs] class Metric: """ Class to create a custom value/cost-sensitive metric. The metric is defined by a cost matrix and a strategy for computing the metric. The cost matrix defines the costs and benefits associated with each type of prediction outcome (true positive, true negative, false positive, false negative). The strategy defines how to compute the metric based on the cost matrix. Read more in the :ref:`User Guide <user_defined_value_metric>`. Parameters ---------- cost_matrix : CostMatrix The cost matrix defining the costs and benefits associated with each type of prediction outcome. strategy : MetricStrategy The strategy to use for computing the metric. - If :class:`~empulse.metrics.MaxProfit`, the metric computes the maximum profit that can be achieved by a classifier. The metric determines the optimal threshold that maximizes the profit. This metric supports the use of stochastic variables. - If :class:`~empulse.metrics.Cost`, the metric computes the expected cost loss of a classifier. This metric supports passing instance-dependent costs in the form of array-likes. This metric does not support stochastic variables. - If :class:`~empulse.metrics.Savings`, the metric computes the savings that can be achieved by a classifier over a naive classifier which always predicts 0 or 1 (whichever is better). This metric supports passing instance-dependent costs in the form of array-likes. This metric does not support stochastic variables. Attributes ---------- tp_benefit : sympy.Expr The benefit of a true positive. See :meth:`~empulse.metrics.Metric.add_tp_benefit` for more details. tn_benefit : sympy.Expr The benefit of a true negative. See :meth:`~empulse.metrics.Metric.add_tn_benefit` for more details. fp_benefit : sympy.Expr The benefit of a false positive. See :meth:`~empulse.metrics.Metric.add_fp_benefit` for more details. fn_benefit : sympy.Expr The benefit of a false negative. See :meth:`~empulse.metrics.Metric.add_fn_benefit` for more details. tp_cost : sympy.Expr The cost of a true positive. See :meth:`~empulse.metrics.Metric.add_tp_cost` for more details. tn_cost : sympy.Expr The cost of a true negative. See :meth:`~empulse.metrics.Metric.add_tn_cost` for more details. fp_cost : sympy.Expr The cost of a false positive. See :meth:`~empulse.metrics.Metric.add_fp_cost` for more details. fn_cost : sympy.Expr The cost of a false negative. See :meth:`~empulse.metrics.Metric.add_fn_cost` for more details. direction: Direction Whether the metric is to be maximized or minimized. Examples -------- Reimplementing :func:`~empulse.metrics.empc_score` using the :class:`Metric` class. .. code-block:: python import sympy as sp from empulse.metrics import Metric, MaxProfit, CostMatrix clv, d, f, alpha, beta = sp.symbols( 'clv d f alpha beta' ) # define deterministic variables gamma = sp.stats.Beta('gamma', alpha, beta) # define gamma to follow a Beta distribution cost_matrix = ( CostMatrix() .add_tp_benefit(gamma * (clv - d - f)) # when churner accepts offer .add_tp_benefit((1 - gamma) * -f) # when churner does not accept offer .add_fp_cost(d + f) # when you send an offer to a non-churner .alias({'incentive_cost': 'd', 'contact_cost': 'f'}) ) empc_score = Metric(cost_matrix, MaxProfit()) y_true = [1, 0, 1, 0, 1] y_proba = [0.9, 0.1, 0.8, 0.2, 0.7] empc_score(y_true, y_proba, clv=100, incentive_cost=10, contact_cost=1, alpha=6, beta=14) Reimplementing :func:`~empulse.metrics.expected_cost_loss_churn` using the :class:`Metric` class. .. code-block:: python import sympy as sp from empulse.metrics import Metric, Cost, CostMatrix clv, delta, f, gamma = sp.symbols('clv delta f gamma') cost_matrix = ( CostMatrix() .add_tp_benefit(gamma * (clv - delta * clv - f)) # when churner accepts offer .add_tp_benefit((1 - gamma) * -f) # when churner does not accept offer .add_fp_cost(delta * clv + f) # when you send an offer to a non-churner .alias({'incentive_fraction': 'delta', 'contact_cost': 'f', 'accept_rate': 'gamma'}) ) cost_loss = Metric(cost_matrix, Cost()) y_true = [1, 0, 1, 0, 1] y_proba = [0.9, 0.1, 0.8, 0.2, 0.7] cost_loss( y_true, y_proba, clv=100, incentive_fraction=0.05, contact_cost=1, accept_rate=0.3 ) """ def __init__(self, cost_matrix: CostMatrix, strategy: MetricStrategy) -> None: self.cost_matrix = cost_matrix self.strategy = strategy self.strategy.build( tp_benefit=self.tp_benefit, tn_benefit=self.tn_benefit, fp_cost=self.fp_cost, fn_cost=self.fn_cost, ) @property def __name__(self) -> str: # noqa: PLW3201 return self.strategy.name @property def tp_benefit(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.tp_benefit @property def tn_benefit(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.tn_benefit @property def fp_benefit(self) -> sympy.Expr: # noqa: D102 return -self.cost_matrix.fp_cost @property def fn_benefit(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.fn_benefit @property def tp_cost(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.tp_cost @property def tn_cost(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.tn_cost @property def fp_cost(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.fp_cost @property def fn_cost(self) -> sympy.Expr: # noqa: D102 return self.cost_matrix.fn_cost @property def direction(self) -> Direction: # noqa: D102 return self.strategy.direction @property def _all_symbols(self) -> set[str]: """Return a set of all symbols used in the cost matrix.""" all_symbols = ( self.tp_cost.free_symbols | self.tn_cost.free_symbols | self.fp_cost.free_symbols | self.fn_cost.free_symbols | self.cost_matrix._aliases.keys() ) # Extract parameters from stochastic variables stochastic_params = set() for expr in [ self.cost_matrix.tp_cost, self.cost_matrix.tn_cost, self.cost_matrix.fp_cost, self.cost_matrix.fn_cost, ]: for atom in expr.atoms(sympy.stats.rv.RandomSymbol): pspace = atom.pspace if hasattr(pspace, 'distribution') and hasattr(pspace.distribution, 'args'): for arg in pspace.distribution.args: stochastic_params.update(arg.free_symbols) return {str(symbol) for symbol in all_symbols | stochastic_params} @property def _all_parameters(self) -> set[str]: """Return a set of cost matrix parameters which can be used.""" all_symbols = ( self.tp_cost.free_symbols | self.tn_cost.free_symbols | self.fp_cost.free_symbols | self.fn_cost.free_symbols | self.cost_matrix._aliases.keys() ) # Extract parameters from stochastic variables stochastic_symbols = set() stochastic_params = set() for expr in [ self.cost_matrix.tp_cost, self.cost_matrix.tn_cost, self.cost_matrix.fp_cost, self.cost_matrix.fn_cost, ]: for atom in expr.atoms(sympy.stats.rv.RandomSymbol): stochastic_symbols.add(atom) pspace = atom.pspace if hasattr(pspace, 'distribution') and hasattr(pspace.distribution, 'args'): for arg in pspace.distribution.args: stochastic_params.update(arg.free_symbols) return {str(symbol) for symbol in (all_symbols | stochastic_params) - stochastic_symbols} @property def _is_stochastic(self) -> bool: all_symbols = ( self.tp_cost.free_symbols | self.tn_cost.free_symbols | self.fp_cost.free_symbols | self.fn_cost.free_symbols | self.cost_matrix._aliases.keys() ) return any(sympy.stats.rv.is_random(symbol) for symbol in set(all_symbols)) @property def _is_deterministic(self) -> bool: return not self._is_stochastic def _prepare_parameters(self, **kwargs: FloatArrayLike | float) -> dict[str, FloatNDArray | float]: """Swap aliases with the appropriate symbols and convert the values to numpy arrays.""" # Map aliases to the appropriate symbols for alias, symbol in self.cost_matrix._aliases.items(): if alias in kwargs: kwargs[symbol] = kwargs.pop(alias) # Use default values if not provided in kwargs for key, value in self.cost_matrix._defaults.items(): kwargs.setdefault(key, value) for key, value in kwargs.items(): if not isinstance(value, Real | str): kwargs[key] = np.asarray(value).reshape(-1) # convert any ints to floats for key, value in kwargs.items(): if isinstance(value, np.ndarray) and not np.issubdtype(value.dtype, np.floating): kwargs[key] = value.astype(np.float64) elif isinstance(value, int): kwargs[key] = float(value) kwargs: dict[str, FloatNDArray | float] # redefine kwargs as mypy doesn't understand the above return kwargs
[docs] def __call__(self, y_true: FloatArrayLike, y_score: FloatArrayLike, **parameters: FloatArrayLike | float) -> float: """ Compute the metric score or loss. Parameters ---------- y_true: array-like of shape (n_samples,) The ground truth labels. y_score: array-like of shape (n_samples,) The predicted labels, probabilities, or decision scores (based on the chosen metric). - If :class:`~empulse.metrics.MaxProfit`, the predicted labels are the decision scores. - If :class:`~empulse.metrics.Cost`, the predicted labels are the (calibrated) probabilities. - If :class:`~empulse.metrics.Savings`, the predicted labels are the (calibrated) probabilities. parameters: float or array-like of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- score: float The computed metric score or loss. """ y_true = np.asarray(y_true) y_score = np.asarray(y_score) parameters = self._prepare_parameters(**parameters) return self.strategy.score(y_true, y_score, **parameters)
[docs] def optimal_threshold( self, y_true: FloatArrayLike, y_score: FloatArrayLike, **parameters: FloatArrayLike | float ) -> FloatNDArray | float: """ Compute the optimal classification threshold(s). i.e., the score threshold at which an observation should be classified as positive to optimize the metric. For instance-dependent costs and benefits, this will return an array of thresholds, one for each sample. For class-dependent costs and benefits, this will return a single threshold value. Parameters ---------- y_true: array-like of shape (n_samples,) The ground truth labels. y_score: array-like of shape (n_samples,) The predicted labels, probabilities, or decision scores (based on the chosen metric). - If :class:`~empulse.metrics.MaxProfit`, the predicted labels are the decision scores. - If :class:`~empulse.metrics.Cost`, the predicted labels are the (calibrated) probabilities. - If :class:`~empulse.metrics.Savings`, the predicted labels are the (calibrated) probabilities. parameters: float or array-like of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- optimal_threshold: float or NDArray of shape (n_samples,) The optimal classification threshold(s). """ y_true = np.asarray(y_true) y_score = np.asarray(y_score) parameters = self._prepare_parameters(**parameters) return self.strategy.optimal_threshold(y_true, y_score, **parameters)
[docs] def optimal_rate( self, y_true: FloatArrayLike, y_score: FloatArrayLike, **parameters: FloatArrayLike | float ) -> float: """ Compute the optimal predicted positive rate. i.e., the fraction of observations that should be classified as positive to optimize the metric. Parameters ---------- y_true: array-like of shape (n_samples,) The ground truth labels. y_score: array-like of shape (n_samples,) The predicted labels, probabilities, or decision scores (based on the chosen metric). - If :class:`~empulse.metrics.MaxProfit`, the predicted labels are the decision scores. - If :class:`~empulse.metrics.Cost`, the predicted labels are the (calibrated) probabilities. - If :class:`~empulse.metrics.Savings`, the predicted labels are the (calibrated) probabilities. parameters: float or array-like of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- optimal_rate: float The optimal predicted positive rate. """ y_true = np.asarray(y_true) y_score = np.asarray(y_score) parameters = self._prepare_parameters(**parameters) return self.strategy.optimal_rate(y_true, y_score, **parameters)
def _logit_objective( self, features: FloatNDArray, y_true: FloatNDArray, C: float, l1_ratio: float, soft_threshold: bool, fit_intercept: bool, **parameters: FloatNDArray | float, ) -> Callable[[FloatNDArray], tuple[float, FloatNDArray]]: """ Compute the metric loss and its gradient with respect to the logistic regression weights. Parameters ---------- features : NDArray of shape (n_samples, n_features) The features of the samples. weights : NDArray of shape (n_features,) The weights of the logistic regression model. y_true : NDArray of shape (n_samples,) The ground truth labels. parameters : float or NDArray of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- value : float The metric loss to be minimized. gradient : NDArray of shape (n_features,) The gradient of the metric loss with respect to the logistic regression weights. """ parameters = self._prepare_parameters(**parameters) if y_true.ndim == 1: y_true = np.expand_dims(y_true, axis=1) for key, value in parameters.items(): if isinstance(value, np.ndarray) and value.ndim == 1: parameters[key] = np.expand_dims(value, axis=1) return self.strategy.logit_objective( features=features, y_true=y_true, C=C, l1_ratio=l1_ratio, soft_threshold=soft_threshold, fit_intercept=fit_intercept, **parameters, ) def _prepare_logit_objective( self, features: FloatNDArray, y_true: FloatNDArray, **parameters: FloatNDArray | float ) -> tuple[FloatNDArray, FloatNDArray, FloatNDArray]: """ Compute the constant term of the loss and gradient of the metric wrt logistic regression coefficients. Parameters ---------- features : NDArray of shape (n_samples, n_features) The features of the samples. y_true : NDArray of shape (n_samples,) The ground truth labels. parameters : float or NDArray of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- gradient_const : NDArray of shape (n_samples, n_features) The constant term of the gradient. loss_const1 : NDArray of shape (n_features,) The first constant term of the loss function. loss_const2 : NDArray of shape (n_features,) The second constant term of the loss function. """ parameters = self._prepare_parameters(**parameters) for key, value in parameters.items(): if isinstance(value, np.ndarray) and value.ndim == 1: parameters[key] = np.expand_dims(value, axis=1) return self.strategy.prepare_logit_objective(features, y_true, **parameters) def _gradient_boost_objective( self, y_true: FloatNDArray, y_score: FloatNDArray, **parameters: FloatNDArray | float ) -> tuple[FloatNDArray, FloatNDArray]: """ Compute the gradient and hessian of the metric loss with respect to the gradient boosting weights. Parameters ---------- y_true : NDArray of shape (n_samples,) The ground truth labels. y_score : NDArray of shape (n_samples,) The predicted probabilities or decision scores. parameters : float or NDArray of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- gradient : NDArray of shape (n_samples,) The gradient of the metric loss with respect to the gradient boosting weights. hessian : NDArray of shape (n_samples,) The hessian of the metric loss with respect to the gradient boosting weights. """ parameters = self._prepare_parameters(**parameters) # y_proba = scipy.special.expit(y_score) y_proba = y_score gradient, hessian = self.strategy.gradient_boost_objective(y_true, y_proba, **parameters) return gradient, hessian def _prepare_boost_objective(self, y_true: FloatNDArray, **parameters: FloatNDArray | float) -> FloatNDArray: """ Compute the gradient's constant term of the metric wrt gradient boost. Parameters ---------- y_true : NDArray of shape (n_samples,) The ground truth labels. parameters : float or NDArray of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. - If ``float``, the same value is used for all samples (class-dependent). - If ``array-like``, the values are used for each sample (instance-dependent). Returns ------- gradient_const : NDArray of shape (n_samples, n_features) The constant term of the gradient. """ parameters = self._prepare_parameters(**parameters) for key, value in parameters.items(): if isinstance(value, np.ndarray) and value.ndim == 1: parameters[key] = np.expand_dims(value, axis=1) return self.strategy.prepare_boost_objective(y_true, **parameters) def _evaluate_costs( self, **parameters: FloatNDArray | float ) -> tuple[ FloatNDArray | float, FloatNDArray | float, FloatNDArray | float, FloatNDArray | float, ]: """ Evaluate the costs expressions. Parameters ---------- parameters : float or NDArray of shape (n_samples,) The parameter values for the costs and benefits defined in the metric. If any parameter is a stochastic variable, you should pass values for their distribution parameters. You can set the parameter values for either the symbol names or their aliases. Returns ------- fp_cost : float or NDArray of shape (n_samples,) The false positive cost(s). fn_cost : float or NDArray of shape (n_samples,) The false negative cost(s). tp_cost : float or NDArray of shape (n_samples,) The true positive cost(s). tn_cost : float or NDArray of shape (n_samples,) The true negative cost(s). """ parameters = self._prepare_parameters(**parameters) fp_cost = _evaluate_expression(self.fp_cost, **parameters) fn_cost = _evaluate_expression(self.fn_cost, **parameters) tp_cost = _evaluate_expression(self.tp_cost, **parameters) tn_cost = _evaluate_expression(self.tn_cost, **parameters) return fp_cost, fn_cost, tp_cost, tn_cost def __repr__(self) -> str: return f'{self.__class__.__name__}(cost_matrix={self.cost_matrix}, strategy={self.strategy})' def _repr_latex_(self) -> str: return self.strategy.to_latex( tp_benefit=self.tp_benefit, tn_benefit=self.tn_benefit, fp_cost=self.fp_cost, fn_cost=self.fn_cost )