1313
1414
1515class ROC :
16- """ROC utility for Discriminative Power and visualization.
16+ r """ROC utility for Discriminative Power and visualization.
1717
1818 We recall that a :ref:`Discriminative Power <discriminative_power>`
1919 only depends on the Pareto front of all the :math:`(FP, TP)` tuples
2020 when thresholding with every possible threshold. Per convention:
2121
22- #. The thresholding test is ``score <= tau``;
22+ #. The thresholding test is ``score <= threshold``.
2323 #. **Positive** (*i.e.* OoD) samples should verify this and thus
24- have a **low score**;
25- #. Scores must not be ``nan`` or ``-inf`` (ensuring validity of the
26- first note in :attr:`pareto`);
24+ have a **low score**.
25+ #. Scores must not be ``nan``.
2726
2827 Arguments
2928 ---------
@@ -33,6 +32,25 @@ class ROC:
3332 scores: ``ArrayLike``
3433 The score of samples. Shape ``(n_samples,)``.
3534
35+ Raises
36+ ------
37+ :exc:`AssertionError`
38+ If there is no positive (*resp.* negative) labels.
39+ :exc:`AssertionError`
40+ If there is at least one ``nan`` score.
41+
42+ Note
43+ ----
44+ .. role:: bsc
45+ :class: bsc
46+
47+ If a negative (*i.e.* InD) sample has a score of :math:`-\infty`,
48+ then the ROC curve would theoretically start with a *nonzero*
49+ :attr:`~ROC.FPR`. In this case, for consistency in
50+ :ref:`discriminative_power` definitions, we artificially add the
51+ point :math:`(0, 0)`, corresponding to the trivial :bsc:`False`
52+ classifier.
53+
3654 """
3755
3856 def __init__ (self , labels : ArrayLike , scores : ArrayLike ) -> None :
@@ -49,7 +67,6 @@ def _preprocess(self, labels: ArrayLike, scores: ArrayLike) -> None:
4967 check (labels_np .any ())
5068 check (not labels_np .all ())
5169 check (not np .isnan (scores ).any ())
52- check (- np .inf < scores_np .min ())
5370
5471 sorter = np .argsort (scores )
5572 self ._scores = scores_np [sorter ]
@@ -67,18 +84,20 @@ def _compute_front(self) -> None:
6784 # ``inf`` (considered self equal). Rests on ``scores`` being
6885 # sorted. Faster than ``np.unique`` which keeps first occurrence
6986 unique_mask = scores != np .r_ [scores [1 :], np .nan ]
70- PP = np .where (unique_mask )[0 ]
71- TP = self ._labels .cumsum ()[PP ]
72- FP = PP - TP + 1
73- attainable_fptp = np .insert (np .c_ [FP , TP ], 0 , 0 , 0 )
74- pareto_mask = (np .diff (attainable_fptp [:, 0 ], append = inf ) > 0 ) & (
75- np .diff (attainable_fptp [:, 1 ], prepend = - inf ) > 0
76- )
77- self ._pareto = attainable_fptp [pareto_mask ]
87+ unique_thresholds = np .insert (scores [unique_mask ], unique_mask .sum (), inf )
88+ PP = np .where (unique_mask )[0 ] + 1 # Predicted Positive
89+ TP = self ._labels .cumsum ()[PP - 1 ]
90+ FP = PP - TP
91+
92+ # Add ``(0, 0)``
93+ unique_thresholds = np .insert (unique_thresholds , 0 , - inf )
94+ FP = np .insert (FP , 0 , 0 )
95+ TP = np .insert (TP , 0 , 0 )
96+
97+ pareto_mask = (np .diff (FP , append = inf ) > 0 ) & (np .diff (TP , prepend = - inf ) > 0 )
7898 pareto_idx = np .where (pareto_mask )[0 ]
79- self ._thresholds = np .insert (scores [unique_mask ], [0 , len (PP )], [- inf , inf ])[
80- [pareto_idx , pareto_idx + 1 ]
81- ].T
99+ self ._pareto = np .c_ [FP , TP ][pareto_mask ]
100+ self ._thresholds = unique_thresholds [[pareto_idx , pareto_idx + 1 ]].T
82101 self ._N , self ._P = int (FP [- 1 ]), int (TP [- 1 ])
83102
84103 def _compute_convex_hull (self ) -> None :
@@ -107,33 +126,43 @@ def P(self) -> int:
107126
108127 @property
109128 def pareto (self ) -> NDArray [np .integer ]:
110- """Ordered :math:`(FP, TP)` tuples defining the Pareto front.
129+ r """Ordered :math:`(FP, TP)` tuples defining the Pareto front.
111130
112131 Returns
113132 -------
114133 pareto: ``NDArray[np.integer]``
115- Shape ``(n_points_pareto , 2)``.
134+ Shape ``(n_pareto_points , 2)``.
116135
117136 Note
118137 ----
119138 The following are always true:
120139
121- - ``self.pareto[0, 0] == 0`` since ``-inf`` scores are
122- prohibited;
140+ - ``self.pareto[0, 0] == 0`` (see :class:`ROC` note);
123141 - ``self.pareto[-1, 1] == self.P``.
124142
125143 """
126144 return self ._pareto
127145
128146 @property
129147 def thresholds (self ) -> NDArray [np .floating ]:
130- """The threshold intervals associated with Pareto points.
148+ r """The threshold intervals associated with Pareto points.
131149
132150 Returns
133151 -------
134152 thresholds: ``NDArray[np.floating]``
135- Convention: lower bound is included, higher bound is
136- excluded (unless ``inf``). Shape ``(n_points_pareto, 2)``.
153+ Intervals for thresholds, to achieve the corresponding
154+ :math:`(FP, TP)` point from :attr:`~ROC.pareto`. The lower
155+ bound is included and the upper bound is excluded, with the
156+ two following exceptions.
157+
158+ 1. A :math:`+\infty` upper bound is included if and only
159+ if ``self.pareto[-1, 0] == self.N``.
160+ 2. A :math:`-\infty` upper bound is a special case for the
161+ point :math:`(0, 0)`, when it is not attainable via
162+ thresholding because a negative sample has a score of
163+ :math:`-\infty`.
164+
165+ Shape ``(n_pareto_points, 2)``.
137166
138167 """
139168 return self ._thresholds
@@ -160,7 +189,7 @@ def FP(self) -> NDArray[np.integer]:
160189 Returns
161190 -------
162191 FP: ``NDArray[np.integer]``
163- Shape ``(n_points_pareto ,)``.
192+ Shape ``(n_pareto_points ,)``.
164193
165194 """
166195 return self .pareto [:, 0 ]
@@ -177,7 +206,7 @@ def TP(self) -> NDArray[np.integer]:
177206 Returns
178207 -------
179208 TP: ``NDArray[np.integer]``
180- Shape ``(n_points_pareto ,)``.
209+ Shape ``(n_pareto_points ,)``.
181210
182211 """
183212 return self .pareto [:, 1 ]
@@ -194,7 +223,7 @@ def FN(self) -> NDArray[np.integer]:
194223 Returns
195224 -------
196225 FN: ``NDArray[np.integer]``
197- Shape ``(n_points_pareto ,)``.
226+ Shape ``(n_pareto_points ,)``.
198227
199228 """
200229 return self .P - self .TP
@@ -211,7 +240,7 @@ def TN(self) -> NDArray[np.integer]:
211240 Returns
212241 -------
213242 TN: ``NDArray[np.integer]``
214- Shape ``(n_points_pareto ,)``.
243+ Shape ``(n_pareto_points ,)``.
215244
216245 """
217246 return self .N - self .FP
@@ -228,7 +257,7 @@ def FPR(self) -> NDArray[np.floating]:
228257 Returns
229258 -------
230259 FPR: ``NDArray[np.floating]``
231- Shape ``(n_points_pareto ,)``.
260+ Shape ``(n_pareto_points ,)``.
232261
233262 """
234263 return self .FP / self .N
@@ -245,7 +274,7 @@ def TPR(self) -> NDArray[np.floating]:
245274 Returns
246275 -------
247276 TPR: ``NDArray[np.floating]``
248- Shape ``(n_points_pareto ,)``.
277+ Shape ``(n_pareto_points ,)``.
249278
250279 """
251280 return self .TP / self .P
@@ -262,7 +291,7 @@ def FNR(self) -> NDArray[np.floating]:
262291 Returns
263292 -------
264293 FNR: ``NDArray[np.floating]``
265- Shape ``(n_points_pareto ,)``.
294+ Shape ``(n_pareto_points ,)``.
266295
267296 """
268297 return self .FN / self .P
@@ -279,7 +308,7 @@ def TNR(self) -> NDArray[np.floating]:
279308 Returns
280309 -------
281310 TNR: ``NDArray[np.floating]``
282- Shape ``(n_points_pareto ,)``.
311+ Shape ``(n_pareto_points ,)``.
283312
284313 """
285314 return self .TN / self .N
0 commit comments