Source code for sorbetto.ranking.ranking_score

import logging
import math
from typing import cast, overload

import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.axes_grid1 import make_axes_locatable

from sorbetto.core.importance import Importance, _parse_importance
from sorbetto.geometry.bilinear_curve import BilinearCurve
from sorbetto.geometry.conic import Conic
from sorbetto.geometry.line import Line
from sorbetto.geometry.pencil_of_lines import PencilOfLines
from sorbetto.performance.abstract_score import AbstractScore
from sorbetto.performance.constraint_fixed_class_priors import (
    ConstraintFixedClassPriors,
)
from sorbetto.performance.finite_set_of_two_class_classification_performances import (
    FiniteSetOfTwoClassClassificationPerformances,
    _parse_performance,
)
from sorbetto.performance.roc import _setupROC
from sorbetto.performance.two_class_classification_performance import (
    TwoClassClassificationPerformance,
)


[docs] class RankingScore(AbstractScore): def __init__( self, importance: Importance, constraint=None, name: str | None = None, abbreviation: str | None = None, symbol: str | None = None, ): """ Args: importance (Importance): _description_ constraint (_type_, optional): _description_. Defaults to None. name (str | None, optional): _description_. Defaults to None. abbreviation (str | None, optional): _description_. Defaults to None. symbol (str | None, optional): _description_. Defaults to None. Raises: TypeError: _description_ """ if not isinstance(importance, Importance): raise TypeError( f"importance must be an instance of Importance, got {type(importance)}" ) self._importance = importance if constraint is not None: if not callable(constraint): raise TypeError( f"constraint must be a callable, got {type(constraint)}" ) self._constraint = constraint default_name = "Ranking Score R_I for I(tn)={:g}, I(fp)={:g}, I(fn)={:g}, I(tp)={:g}".format( importance.itn, importance.ifp, importance.ifn, importance.itp ) default_abbreviation = "RS" default_symbol = "$R_I$" AbstractScore.__init__( self, default_name, default_abbreviation, default_symbol, name, abbreviation, symbol, ) @property def importance(self) -> Importance: return self._importance # TODO: this method might not be in the right class. Should we move it in ParameterizationDefault ?
[docs] @staticmethod def equivalent( p1: TwoClassClassificationPerformance, p2: TwoClassClassificationPerformance, ) -> Conic: """ Computes, on the Tile with the default parameterization, the locus of performance orderings for which the performances `p1` and `p2` are equivalent. This locus is a curve, a conic section. Args: p1 (TwoClassClassificationPerformance): _description_ p2 (TwoClassClassificationPerformance): _description_ Returns: Conic: the conic section. """ # ( itn ptn1 + itp ptp1 ) / ( itn ptn1 + ifp pfp1 + ifn pfn1 + itp ptp1 ) = ( itn ptn2 + itp ptp2 ) / ( itn ptn2 + ifp pfp2 + ifn pfn2 + itp ptp2 ) # ( itn ptn1 + itp ptp1 ) ( itn ptn2 + ifp pfp2 + ifn pfn2 + itp ptp2 ) = ( itn ptn2 + itp ptp2 ) ( itn ptn1 + ifp pfp1 + ifn pfn1 + itp ptp1 ) # ( itn ptn1 + itp ptp1 ) ( ifp pfp2 + ifn pfn2 ) = ( itn ptn2 + itp ptp2 ) ( ifp pfp1 + ifn pfn1 ) # ( itn ptn1 + itp ptp1 ) ( ifp pfp2 + ifn pfn2 ) - ( itn ptn2 + itp ptp2 ) ( ifp pfp1 + ifn pfn1 ) = 0 # ( (1-a) ptn1 + a ptp1 ) ( (1-b) pfp2 + b pfn2 ) - ( (1-a) ptn2 + a ptp2 ) ( (1-b) pfp1 + b pfn1 ) = 0 # ( ptn1 + a (ptp1-ptn1) ) ( pfp2 + b (pfn2-pfp2) ) - ( ptn2 + a (ptp2-ptn2) ) ( pfp1 + b (pfn1-pfp1) ) = 0 # # K + Ka a + Kb b + Kab a b = 0 # # With: # K = ptn1 pfp2 - ptn2 pfp1 # Ka = (ptp1-ptn1)pfp2 - (ptp2-ptn2)pfp1 # Kb = ptn1(pfn2-pfp2) - ptn2(pfn1-pfp1) # Kab = (ptp1-ptn1)(pfn2-pfp2) - (ptp2-ptn2)(pfn1-pfp1) ptn1 = p1.ptn pfp1 = p1.pfp pfn1 = p1.pfn ptp1 = p1.ptp ptn2 = p2.ptn pfp2 = p2.pfp pfn2 = p2.pfn ptp2 = p2.ptp K = ptn1 * pfp2 - ptn2 * pfp1 Ka = (ptp1 - ptn1) * pfp2 - (ptp2 - ptn2) * pfp1 Kb = ptn1 * (pfn2 - pfp2) - ptn2 * (pfn1 - pfp1) Kab = (ptp1 - ptn1) * (pfn2 - pfp2) - (ptp2 - ptn2) * (pfn1 - pfp1) # return Conic(0.0, Kab, 0.0, Ka, Kb, K, "equivalent") return BilinearCurve(Kab, Ka, Kb, K, "equivalent")
[docs] def isCanonical(self, tol=1e-8) -> bool: """ See :cite:t:`Pierard2024TheTile-arxiv`, Definition 1. """ itn = self._importance.itn ifp = self._importance.ifp ifn = self._importance.ifn itp = self._importance.itp canonical_for_satisfying = math.isclose(itn + itp, 1.0, abs_tol=tol) canonical_for_unsatisfying = math.isclose(ifp + ifn, 1.0, abs_tol=tol) return canonical_for_satisfying and canonical_for_unsatisfying
[docs] def drawInROC( self, fig, ax, priorPos: float, show_values_map: bool = True, show_iso_value_lines: bool = True, show_colorbar: bool = True, show_no_skills: bool = True, show_priors: bool = True, show_unbiased: bool = True, ) -> None: """ See https://en.wikipedia.org/wiki/Receiver_operating_characteristic Args: fig (_type_): _description_ ax (_type_): _description_ priorPos (float): prior of the positive class :math:`\\pi_+ \\in (0,1)` show_values_map (bool, optional): _description_. Defaults to True. show_iso_value_lines (bool, optional): _description_. Defaults to True. show_colorbar (bool, optional): _description_. Defaults to True. show_no_skills (bool, optional): _description_. Defaults to True. show_priors (bool, optional): _description_. Defaults to True. show_unbiased (bool, optional): _description_. Defaults to True. """ assert isinstance(show_values_map, bool) assert isinstance(show_iso_value_lines, bool) assert isinstance(show_colorbar, bool) assert isinstance(show_no_skills, bool) assert isinstance(show_priors, bool) assert isinstance(show_unbiased, bool) # Check priors assert isinstance(priorPos, float) assert priorPos > 0.0 assert priorPos < 1.0 priorNeg = 1.0 - priorPos # TNR, FPR, FNR, TPR grid_size = 1001 vec_fpr = vec_tpr = np.linspace(0, 1, num=grid_size) mat_fpr, mat_tpr = np.meshgrid(vec_fpr, vec_tpr, indexing="xy") mat_tnr = 1 - mat_fpr mat_fnr = 1 - mat_tpr # PTN, PFP, PFN, PTP mat_ptn = mat_tnr * priorNeg mat_pfp = mat_fpr * priorNeg mat_pfn = mat_fnr * priorPos mat_ptp = mat_tpr * priorPos # ITN, IFP, IFN, ITP itn = self._importance.itn ifp = self._importance.ifp ifn = self._importance.ifn itp = self._importance.itp # Values taken by the scores mat_satisfying = itn * mat_ptn + itp * mat_ptp mat_unsatisfying = ifp * mat_pfp + ifn * mat_pfn mat_values = mat_satisfying / (mat_satisfying + mat_unsatisfying) if show_values_map: extent = 0, 1, 0, 1 cmap = plt.cm.bone # type: ignore im = ax.imshow( mat_values, extent=extent, origin="lower", interpolation="bilinear", cmap=cmap, ) if show_colorbar: divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad="5%") fig.colorbar(im, cax) if show_iso_value_lines: cs = ax.contour( vec_fpr, vec_tpr, mat_values, levels=np.linspace(0, 1, 21), colors="cornflowerblue", ) ax.clabel(cs, inline=True, fontsize=8) _setupROC( fig, ax, priorPos=priorPos, show_no_skills=show_no_skills, show_priors=show_priors, show_unbiased=show_unbiased, )
[docs] def getPencilInROC(self, priorPos) -> PencilOfLines: assert isinstance(priorPos, float) assert priorPos > 0 assert priorPos < 1 priorNeg = 1 - priorPos itn = self._importance.itn ifp = self._importance.ifp ifn = self._importance.ifn itp = self._importance.itp # TODO: generalize what we do here: # it could be useful to be able to find the line for any value # When the score takes the value 0: # itn ptn + itp ptp = 0 # <=> itn ( (1-fpr) priorNeg ) + itp ( tpr priorPos ) = 0 # <=> fpr ( - itn priorNeg ) + tpr ( itp priorPos ) + ( itn priorNeg ) = 0 a = -itn * priorNeg b = itp * priorPos c = itn * priorNeg line_0 = Line(a, b, c, "line for value 0") # When the score takes the value 1: # ifp pfp + ifn pfn = 0 # <=> ifp ( fpr priorNeg ) + ifn ( (1-tpr) priorPos ) = 0 # <=> fpr ( ifp priorNeg ) + tpr ( - ifn priorPos ) + ( ifn priorPos ) = 0 a = ifp * priorNeg b = -ifn * priorPos c = ifn * priorPos line_1 = Line(a, b, c, "line for value 1") name = "pencil in ROC for score {} and a prior of positive class of {}".format( self.name, priorPos ) return PencilOfLines(line_0, line_1, name)
@overload @staticmethod def _compute( importance: Importance, performance: TwoClassClassificationPerformance, ) -> float: ... # Importance + Performance mixed @overload @staticmethod def _compute( importance: Importance, performance: FiniteSetOfTwoClassClassificationPerformances | np.ndarray, ) -> np.ndarray: ... @overload @staticmethod def _compute( importance: list[Importance] | np.ndarray, performance: TwoClassClassificationPerformance, ) -> np.ndarray: ... # Importance + Performance array mode @overload @staticmethod def _compute( importance: list[Importance] | np.ndarray, performance: FiniteSetOfTwoClassClassificationPerformances | np.ndarray, ) -> np.ndarray: ... # I=float, P=float → float @overload @staticmethod def _compute( *, itn: float, ifp: float, ifn: float, itp: float, ptn: float, pfp: float, pfn: float, ptp: float, ) -> float: ... # I=float, P=np.ndarray → ndarray @overload @staticmethod def _compute( *, itn: float, ifp: float, ifn: float, itp: float, ptn: np.ndarray, pfp: np.ndarray, pfn: np.ndarray, ptp: np.ndarray, ) -> np.ndarray: ... # I=np.ndarray, P=float → ndarray @overload @staticmethod def _compute( *, itn: np.ndarray, ifp: np.ndarray, ifn: np.ndarray, itp: np.ndarray, ptn: float, pfp: float, pfn: float, ptp: float, ) -> np.ndarray: ... # I=np.ndarray, P=np.ndarray → ndarray @overload @staticmethod def _compute( *, itn: np.ndarray, ifp: np.ndarray, ifn: np.ndarray, itp: np.ndarray, ptn: np.ndarray, pfp: np.ndarray, pfn: np.ndarray, ptp: np.ndarray, ) -> np.ndarray: ... @staticmethod def _compute( importance: Importance | list[Importance] | np.ndarray | None = None, performance: TwoClassClassificationPerformance | FiniteSetOfTwoClassClassificationPerformances | np.ndarray | None = None, itn: float | np.ndarray | None = None, ifp: float | np.ndarray | None = None, ifn: float | np.ndarray | None = None, itp: float | np.ndarray | None = None, ptn: float | np.ndarray | None = None, pfp: float | np.ndarray | None = None, pfn: float | np.ndarray | None = None, ptp: float | np.ndarray | None = None, ): itn, ifp, ifn, itp = _parse_importance( importance=importance, itn=itn, ifp=ifp, ifn=ifn, itp=itp ) ptn, pfp, pfn, ptp = _parse_performance( performance=performance, ptn=ptn, pfp=pfp, pfn=pfn, ptp=ptp ) satisfying = ptn * itn + ptp * itp unsatisfying = pfp * ifp + pfn * ifn return satisfying / (satisfying + unsatisfying) def __call__(self, performance: TwoClassClassificationPerformance) -> float: if self._constraint and not self._constraint(performance): logging.warning( f"Performance {performance} does not satisfy the constraint of " f"the ranking score {self._name}" ) return cast( float, RankingScore._compute( itn=self._importance.itn, ifp=self._importance.ifp, ifn=self._importance.ifn, itp=self._importance.itp, ptn=performance.ptn, pfp=performance.pfp, pfn=performance.pfn, ptp=performance.ptp, ), )
[docs] @staticmethod def getTrueNegativeRate() -> "RankingScore": """ True Negative Rate (TNR). Synonyms: specificity, selectivity, inverse recall. """ # See :cite:t:`Pierard2025Foundations`, Section A.7.3 importance = Importance(itn=1, ifp=1, ifn=0, itp=0) name = "True Negative Rate" abbreviation = "TNR" return RankingScore(importance, name=name, abbreviation=abbreviation)
[docs] @staticmethod def getTruePositiveRate() -> "RankingScore": """ True Positive Rate (TPR). Synonyms: sensitivity, recall. """ # See :cite:t:`Pierard2025Foundations`, Section A.7.3 importance = Importance(itn=0, ifp=0, ifn=1, itp=1) name = "True Positive Rate" abbreviation = "TPR" return RankingScore(importance, name=name, abbreviation=abbreviation)
[docs] @staticmethod def getSpecificity() -> "RankingScore": rs = RankingScore.getTrueNegativeRate() rs.rename("Specificity", "Sp") return rs
[docs] @staticmethod def getSelectivity() -> "RankingScore": rs = RankingScore.getTrueNegativeRate() rs.rename("Selectivity") return rs
[docs] @staticmethod def getSensitivity() -> "RankingScore": rs = RankingScore.getTruePositiveRate() rs.rename("Sensitivity") return rs
[docs] @staticmethod def getNegativePredictiveValue() -> "RankingScore": """ Negative Predictive Value (NPV). Synonym: inverse precision """ # See :cite:t:`Pierard2025Foundations`, Section A.7.3 importance = Importance(itn=1, ifp=0, ifn=1, itp=0) name = "Negative Predictive Value" abbreviation = "NPV" return RankingScore(importance, name=name, abbreviation=abbreviation)
[docs] @staticmethod def getPositivePredictiveValue() -> "RankingScore": """ Positive Predictive Value (PPV). Synonym: precision """ # See :cite:t:`Pierard2025Foundations`, Section A.7.3 importance = Importance(itn=0, ifp=1, ifn=0, itp=1) name = "Positive Predictive Value" abbreviation = "PPV" return RankingScore(importance, name=name, abbreviation=abbreviation)
[docs] @staticmethod def getPrecision() -> "RankingScore": rs = RankingScore.getPositivePredictiveValue() rs.rename("Precision", "Pr") return rs
[docs] @staticmethod def getInversePrecision() -> "RankingScore": rs = RankingScore.getNegativePredictiveValue() rs.rename("Inverse Precision", "Pr-Inv") return rs
[docs] @staticmethod def getRecall() -> "RankingScore": rs = RankingScore.getTruePositiveRate() rs.rename("Recall", "Re") return rs
[docs] @staticmethod def getInverseRecall() -> "RankingScore": rs = RankingScore.getTrueNegativeRate() rs.rename("Inverse Recall", "Re-Inv") return rs
[docs] @staticmethod def getIntersectionOverUnion() -> "RankingScore": """ Intersection over Union (IoU). Synonyms: Jaccard index, Jaccard similarity coefficient, Tanimoto coefficient, similarity, critical success index (CSI), threat score. """ importance = Importance(itn=0, ifp=1, ifn=1, itp=1) name = "Intersection over Union" abbreviation = "IoU" return RankingScore(importance, name=name, abbreviation=abbreviation)
[docs] @staticmethod def getInverseIntersectionOverUnion() -> "RankingScore": importance = Importance(itn=1, ifp=1, ifn=1, itp=0) name = "Inverse Intersection over Union" abbreviation = "IoU-Inv" return RankingScore(importance, name=name, abbreviation=abbreviation)
[docs] @staticmethod def getJaccard() -> "RankingScore": rs = RankingScore.getIntersectionOverUnion() rs.rename("Jaccard", "J") return rs
[docs] @staticmethod def getInverseJaccard() -> "RankingScore": rs = RankingScore.getInverseIntersectionOverUnion() rs.rename("Inverse Jaccard", "J-Inv") return rs
[docs] @staticmethod def getTanimotoCoefficient() -> "RankingScore": rs = RankingScore.getIntersectionOverUnion() rs.rename("Tanimoto Coefficient", "TC") return rs
[docs] @staticmethod def getSimilarity() -> "RankingScore": rs = RankingScore.getIntersectionOverUnion() rs.rename("Similarity") return rs
[docs] @staticmethod def getCriticalSuccessIndex() -> "RankingScore": rs = RankingScore.getIntersectionOverUnion() rs.rename("Critical Success Index", "CSI") return rs
[docs] @staticmethod def getF(beta=1.0) -> "RankingScore": if not isinstance(beta, float): raise ValueError(f"beta must be a real number, got {beta}") if math.isnan(beta) or beta < 0: raise ValueError(f"beta must be positive, got {beta}") # See :cite:t:`Pierard2025Foundations`, Section A.7.3 importance = Importance( itn=0, ifp=1 / (1 + beta**2), ifn=beta**2 / (1 + beta**2), itp=1 ) name = "F-score for β={:g}".format(beta) abbreviation = "F{:g}".format(beta) symbol = "$F_{}$".format("{:g}".format(beta)) return RankingScore( importance, name=name, abbreviation=abbreviation, symbol=symbol )
[docs] @staticmethod def getInverseF(beta=1.0) -> "RankingScore": if not isinstance(beta, float): raise ValueError(f"beta must be a real number, got {beta}") if math.isnan(beta) or beta < 0: raise ValueError(f"beta must be positive, got {beta}") importance = Importance( itn=1, ifp=beta**2 / (1 + beta**2), ifn=1 / (1 + beta**2), itp=0 ) name = "Inverse F-score for β={:g}".format(beta) abbreviation = "F{:g}-Inv".format(beta) symbol = "$F_{}{}$".format("{:g}".format(beta), "\\textrm{-}Inv") return RankingScore( importance, name=name, abbreviation=abbreviation, symbol=symbol )
[docs] @staticmethod def getDiceSorensenCoefficient() -> "RankingScore": rs = RankingScore.getF(beta=1.0) rs.rename("Dice-Sørensen coefficient", "DSC") return rs
[docs] @staticmethod def getZijdenbosSimilarityIndex() -> "RankingScore": rs = RankingScore.getF(beta=1.0) rs.rename("Zijdenbos Similarity Index", "ZSI") return rs
[docs] @staticmethod def getCzekanowskiBinaryIndex() -> "RankingScore": rs = RankingScore.getF(beta=1.0) rs.rename("Czekanowski Binary Index", "CBI") return rs
[docs] @staticmethod def getAccuracy() -> "RankingScore": # See :cite:t:`Pierard2025Foundations`, Section A.7.3 importance = Importance(itn=1, ifp=1, ifn=1, itp=1) name = "Accuracy" abbreviation = "A" return RankingScore(importance, name=name, abbreviation=abbreviation)
# @staticmethod # def getMatchingCoefficient() -> "RankingScore": # # SimpleMatchingCoefficient ??? Same as Jaccard ??? # rs = RankingScore.getAccuracy() # rs.rename("MC") # return rs
[docs] @staticmethod def getSkewInsensitiveVersionOfF(priorPos: float) -> "RankingScore": """ The skew-insensitive version of :math:`\\scoreFOne`. Defined in cite:t:`Flach2003TheGeometry`. """ # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) importance = NotImplemented # TODO name = "Skew Insensitive Version of F" abbreviation = "SIVF" return RankingScore( importance, constraint=constraint, name=name, abbreviation=abbreviation )
[docs] @staticmethod def getWeightedAccuracy(priorPos: float, weightPos: float) -> "RankingScore": # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) assert isinstance(weightPos, float) assert weightPos >= 0 assert weightPos <= 1 # See :cite:t:`Pierard2024TheTile-arxiv`, Section A.3.4. importance = NotImplemented # TODO name = "Weighted Accuracy ({:g})".format(weightPos) abbreviation = "WA" return RankingScore( importance, constraint=constraint, name=name, abbreviation=abbreviation )
[docs] @staticmethod def getBalancedAccuracy(priorPos: float) -> "RankingScore": # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) # See :cite:t:`Pierard2025Foundations`, Section A.7.4 importance = NotImplemented # TODO name = "Balanced Accuracy" abbreviation = "BA" return RankingScore( importance, constraint=constraint, name=name, abbreviation=abbreviation )
[docs] @staticmethod def getProbabilityTrueNegative(priorPos: float) -> "RankingScore": # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) # See :cite:t:`Pierard2025Foundations`, Section A.7.4 importance = NotImplemented # TODO name = "Probability of True Negative" abbreviation = "PTN" return RankingScore( importance, constraint=constraint, name=name, abbreviation=abbreviation )
[docs] @staticmethod def getProbabilityFalsePositiveComplenent(priorPos: float) -> "RankingScore": # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) importance = NotImplemented # TODO name = "Complement of the Probability of False Positive" return RankingScore(importance, constraint=constraint, name=name)
[docs] @staticmethod def getProbabilityFalseNegativeComplenent(priorPos: float) -> "RankingScore": # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) importance = NotImplemented # TODO name = "Complement of the Probability of False Negative" return RankingScore(importance, constraint=constraint, name=name)
[docs] @staticmethod def getProbabilityTruePositive(priorPos: float) -> "RankingScore": # The argument `priorPos` is checked in the constructor of the constraint. constraint = ConstraintFixedClassPriors(priorPos) # See :cite:t:`Pierard2025Foundations`, Section A.7.4 importance = NotImplemented # TODO name = "Probability of True Positive" abbreviation = "PTP" return RankingScore( importance, constraint=constraint, name=name, abbreviation=abbreviation )
[docs] @staticmethod def getDetectionRate(priorPos: float) -> "RankingScore": rs = RankingScore.getProbabilityTruePositive(priorPos) rs.rename("Detection Rate", "DR") return rs
[docs] @staticmethod def getRejectionRate(priorPos: float) -> "RankingScore": rs = RankingScore.getProbabilityTrueNegative(priorPos) rs.rename("Rejection Rate", "RR") return rs
def __str__(self): return ( f"Ranking Score: {self.longLabel} with importance {str(self._importance)}" )