Skip to content

Commit 455b0ef

Browse files
authored
Allow -inf scores in ROC (closes #41)
* feat: allow neginf scores in ROC (#19, wip) [skip ci] Signed-off-by: Élie Goudout <elie.goudout@thalesgroup.com> * fix: mypy ran without doc deps (aaab602) [skip ci] Signed-off-by: Élie Goudout <elie.goudout@thalesgroup.com> * doc: note on neginf scores [skip ci] Signed-off-by: Élie Goudout <elie.goudout@thalesgroup.com> * doc: polish/warning neginf scores [skip ci] Signed-off-by: Élie Goudout <elie.goudout@thalesgroup.com> * feat: revert decision: add (0, 0) with disclaimers Signed-off-by: Élie Goudout <elie.goudout@thalesgroup.com> --------- Signed-off-by: Élie Goudout <elie.goudout@thalesgroup.com>
1 parent aaab602 commit 455b0ef

10 files changed

Lines changed: 153 additions & 50 deletions

File tree

docs/src/_static/custom.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,9 @@ div.sphx-glr-download a:hover {
133133
font-weight: bold;
134134
font-style: italic;
135135
}
136+
137+
/* Bold small caps */
138+
.bsc {
139+
font-weight: bold;
140+
font-variant: small-caps;
141+
}

docs/src/tutorials/visualizing_and_evaluating_ood_detection_algorithms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
# These should be defined by your use-case
2626
import torch
27-
from datasets import load_dataset # type: ignore[import-untyped]
27+
from datasets import load_dataset # type: ignore[import-untyped, unused-ignore]
2828

2929
calib_set = load_dataset("ego-thales/cifar10", name="calibration")["unique_split"]
3030
calib_data, calib_labels, _ = calib_set.with_format("torch")[:].values()

scio/eval/classification/discriminative_power.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ def __call__(self, labels: ArrayLike, scores: ArrayLike) -> float:
103103

104104

105105
class AUC(BaseDiscriminativePower):
106-
"""AUC for ROC, potentially partial — in which case normalized.
106+
r"""AUC for ROC, potentially partial — in which case normalized.
107+
108+
With the default arguments, one has
109+
110+
.. math::
111+
112+
AUC = \mathbb{P}(\text{score}_{\text{OoD}}<\text{score}_{\text{InD}}),
113+
114+
when sampling from the reference population.
107115
108116
Arguments
109117
---------

scio/eval/classification/roc.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,16 @@
1313

1414

1515
class 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
-974 Bytes
Loading
-682 Bytes
Loading
-1014 Bytes
Loading
-822 Bytes
Loading

0 commit comments

Comments
 (0)