Skip to content

Commit 1657e29

Browse files
janezdastaric
authored andcommitted
Merge pull request #1954 from ales-erjavec/fixes/evaluate-input-validation
[FIX] Evaluation Results input validation (cherry picked from commit 5cbac0e)
1 parent b34cbcb commit 1657e29

File tree

12 files changed

+364
-24
lines changed

12 files changed

+364
-24
lines changed

Orange/evaluation/testing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def __init__(self, data=None, nmethods=0, *, learners=None, train_data=None,
144144
if nmethods is not None:
145145
self.failed = [False] * nmethods
146146

147-
if data:
147+
if data is not None:
148148
self.data = data if self.store_data else None
149149
self.domain = data.domain
150150
self.dtype = getattr(data.Y, 'dtype', self.dtype)

Orange/widgets/evaluate/owcalibrationplot.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ class OWCalibrationPlot(widget.OWWidget):
3737
priority = 1030
3838
inputs = [("Evaluation Results", Orange.evaluation.Results, "set_results")]
3939

40+
class Warning(widget.OWWidget.Warning):
41+
empty_input = widget.Msg(
42+
"Empty result on input. Nothing to display.")
43+
4044
target_index = settings.Setting(0)
4145
selected_classifiers = settings.Setting([])
4246
display_rug = settings.Setting(True)
@@ -84,7 +88,12 @@ def __init__(self):
8488

8589
def set_results(self, results):
8690
self.clear()
87-
self.results = check_results_adequacy(results, self.Error)
91+
results = check_results_adequacy(results, self.Error)
92+
if results is not None and not results.actual.size:
93+
self.Warning.empty_input()
94+
else:
95+
self.Warning.empty_input.clear()
96+
self.results = results
8897
if self.results is not None:
8998
self._initialize(results)
9099
self._replot()
@@ -125,11 +134,15 @@ def plot_curve(self, clf_idx, target):
125134
sortind = numpy.argsort(probs)
126135
probs = probs[sortind]
127136
ytrue = ytrue[sortind]
128-
# x = numpy.unique(probs)
129-
xmin, xmax = probs.min(), probs.max()
130-
x = numpy.linspace(xmin, xmax, 100)
131-
f = gaussian_smoother(probs, ytrue, sigma=0.15 * (xmax - xmin))
132-
observed = f(x)
137+
if probs.size:
138+
xmin, xmax = probs.min(), probs.max()
139+
x = numpy.linspace(xmin, xmax, 100)
140+
f = gaussian_smoother(probs, ytrue, sigma=0.15 * (xmax - xmin))
141+
observed = f(x)
142+
else:
143+
x = numpy.array([])
144+
observed = numpy.array([])
145+
133146
curve = Curve(x, observed)
134147
curve_item = pg.PlotDataItem(
135148
x, observed, pen=pg.mkPen(self.colors[clf_idx], width=1),

Orange/widgets/evaluate/owconfusionmatrix.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import sklearn.metrics as skl_metrics
1717

1818
import Orange
19+
import Orange.evaluation
1920
from Orange.widgets import widget, settings, gui
2021
from Orange.widgets.utils.annotated_data import (create_annotated_table,
2122
ANNOTATED_DATA_SIGNAL_NAME)
@@ -32,8 +33,13 @@ def confusion_matrix(res, index):
3233
3334
Returns: Confusion matrix
3435
"""
35-
return skl_metrics.confusion_matrix(
36-
res.actual, res.predicted[index])
36+
labels = numpy.arange(len(res.domain.class_var.values))
37+
if not res.actual.size:
38+
# scikit-learn will not return an zero matrix
39+
return numpy.zeros((len(labels), len(labels)))
40+
else:
41+
return skl_metrics.confusion_matrix(
42+
res.actual, res.predicted[index], labels=labels)
3743

3844

3945
BorderRole = next(gui.OrangeUserRole)
@@ -109,6 +115,7 @@ class OWConfusionMatrix(widget.OWWidget):
109115

110116
class Error(widget.OWWidget.Error):
111117
no_regression = Msg("Confusion Matrix cannot show regression results.")
118+
invalid_values = Msg("Evaluation Results input contains invalid values")
112119

113120
def __init__(self):
114121
super().__init__()
@@ -238,6 +245,21 @@ def set_results(self, results):
238245
else:
239246
self.Error.no_regression.clear()
240247

248+
nan_values = False
249+
if results is not None:
250+
assert isinstance(results, Orange.evaluation.Results)
251+
if numpy.any(numpy.isnan(results.actual)) or \
252+
numpy.any(numpy.isnan(results.predicted)):
253+
# Error out here (could filter them out with a warning
254+
# instead).
255+
nan_values = True
256+
results = data = None
257+
258+
if nan_values:
259+
self.Error.invalid_values()
260+
else:
261+
self.Error.invalid_values.clear()
262+
241263
self.results = results
242264
self.data = data
243265

@@ -487,7 +509,6 @@ def send_report(self):
487509

488510
@classmethod
489511
def migrate_settings(cls, settings, version):
490-
super().migrate_settings(settings, version)
491512
if not version:
492513
# For some period of time the 'selected_learner' property was
493514
# changed from List[int] -> int

Orange/widgets/evaluate/owliftcurve.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ def _setup_plot(self):
200200
pen.setCosmetic(True)
201201
self.plot.plot([0, 1], [0, 1], pen=pen, antialias=True)
202202

203+
warning = ""
204+
if not all(c.curve.is_valid for c in curves):
205+
if any(c.curve.is_valid for c in curves):
206+
warning = "Some lift curves are undefined"
207+
else:
208+
warning = "All lift curves are undefined"
209+
210+
self.warning(warning)
211+
203212
def _replot(self):
204213
self.plot.clear()
205214
if self.results is not None:
@@ -231,6 +240,11 @@ def lift_curve_from_results(results, target, clf_idx, subset=slice(0, -1)):
231240
def lift_curve(ytrue, ypred, target=1):
232241
P = numpy.sum(ytrue == target)
233242
N = ytrue.size - P
243+
244+
if P == 0 or N == 0:
245+
# Undefined TP and FP rate
246+
return numpy.array([]), numpy.array([]), numpy.array([])
247+
234248
fpr, tpr, thresholds = skl_metrics.roc_curve(ytrue, ypred, target)
235249
rpp = fpr * (N / (P + N)) + tpr * (P / (P + N))
236250
return rpp, tpr, thresholds

Orange/widgets/evaluate/owpredictions.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -463,17 +463,22 @@ def commit(self):
463463
predictions.metas[:, -newcolumns.shape[1]:] = newcolumns
464464

465465
results = None
466+
# if the input data set contains the true target values, output a
467+
# simple evaluation.Results instance
466468
if self.data.domain.class_var == class_var:
467-
N = len(self.data)
468-
results = Orange.evaluation.Results(self.data, store_data=True)
469+
# omit rows with unknonw target values
470+
nanmask = numpy.isnan(self.data.get_column_view(class_var)[0])
471+
data = self.data[~nanmask]
472+
N = len(data)
473+
results = Orange.evaluation.Results(data, store_data=True)
469474
results.folds = None
470475
results.row_indices = numpy.arange(N)
471-
results.actual = self.data.Y.ravel()
476+
results.actual = data.Y.ravel()
472477
results.predicted = numpy.vstack(
473-
tuple(p.results[0] for p in slots))
478+
tuple(p.results[0][~nanmask] for p in slots))
474479
if classification:
475480
results.probabilities = numpy.array(
476-
[p.results[1] for p in slots])
481+
[p.results[1][~nanmask] for p in slots])
477482
results.learner_names = [p.name for p in slots]
478483

479484
self.send("Predictions", predictions)

Orange/widgets/evaluate/owrocanalysis.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,10 @@ class OWROCAnalysis(widget.OWWidget):
298298
priority = 1010
299299
inputs = [("Evaluation Results", Orange.evaluation.Results, "set_results")]
300300

301+
class Warning(widget.OWWidget.Warning):
302+
empty_results = widget.Msg(
303+
"Empty results on input. There is nothing to display.")
304+
301305
target_index = settings.Setting(0)
302306
selected_classifiers = []
303307

@@ -418,8 +422,10 @@ def set_results(self, results):
418422
self.clear()
419423
self.results = check_results_adequacy(results, self.Error)
420424
if self.results is not None:
421-
self._initialize(results)
425+
self._initialize(self.results)
422426
self._setup_plot()
427+
else:
428+
self.warning()
423429

424430
def clear(self):
425431
"""Clear the widget state."""
@@ -517,7 +523,7 @@ def _setup_plot(self):
517523
if self.display_convex_curve:
518524
self.plot.addItem(graphics.hull_item)
519525

520-
if self.display_def_threshold:
526+
if self.display_def_threshold and curve.is_valid:
521527
points = curve.points
522528
ind = numpy.argmin(numpy.abs(points.thresholds - 0.5))
523529
item = pg.TextItem(
@@ -559,6 +565,8 @@ def _setup_plot(self):
559565
if self.display_convex_curve:
560566
self.plot.addItem(fold.hull_item)
561567
hull_curves = [fold.hull for curve in selected for fold in curve.folds]
568+
else:
569+
assert False
562570

563571
if self.display_convex_hull and hull_curves:
564572
hull = convex_hull(hull_curves)
@@ -578,6 +586,14 @@ def _setup_plot(self):
578586
if self.roc_averaging == OWROCAnalysis.Merge:
579587
self._update_perf_line()
580588

589+
warning = ""
590+
if not all(c.is_valid for c in hull_curves):
591+
if any(c.is_valid for c in hull_curves):
592+
warning = "Some ROC curves are undefined"
593+
else:
594+
warning = "All ROC curves are undefined"
595+
self.warning(warning)
596+
581597
def _on_target_changed(self):
582598
self.plot.clear()
583599
self._setup_plot()
@@ -612,10 +628,13 @@ def _update_perf_line(self):
612628
self.fp_cost, self.fn_cost, self.target_prior / 100.0)
613629

614630
hull = self._rocch
615-
ind = roc_iso_performance_line(m, hull)
616-
angle = numpy.arctan2(m, 1) # in radians
617-
self._perf_line.setAngle(angle * 180 / numpy.pi)
618-
self._perf_line.setPos((hull.fpr[ind[0]], hull.tpr[ind[0]]))
631+
if hull.is_valid:
632+
ind = roc_iso_performance_line(m, hull)
633+
angle = numpy.arctan2(m, 1) # in radians
634+
self._perf_line.setAngle(angle * 180 / numpy.pi)
635+
self._perf_line.setPos((hull.fpr[ind[0]], hull.tpr[ind[0]]))
636+
else:
637+
self._perf_line.setVisible(False)
619638

620639
def onDeleteWidget(self):
621640
self.clear()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import copy
2+
3+
import numpy as np
4+
5+
import Orange.data
6+
import Orange.evaluation
7+
import Orange.classification
8+
9+
from Orange.widgets.tests.base import WidgetTest
10+
from Orange.widgets.evaluate.owcalibrationplot import OWCalibrationPlot
11+
12+
13+
class TestOWCalibrationPlot(WidgetTest):
14+
@classmethod
15+
def setUpClass(cls):
16+
super().setUpClass()
17+
cls.lenses = data = Orange.data.Table("lenses")
18+
cls.res = Orange.evaluation.TestOnTestData(
19+
train_data=data[::2], test_data=data[1::2],
20+
learners=[Orange.classification.MajorityLearner(),
21+
Orange.classification.KNNLearner()],
22+
store_data=True,
23+
)
24+
25+
def setUp(self):
26+
super().setUp()
27+
self.widget = self.create_widget(OWCalibrationPlot) # type: OWCalibrationPlot
28+
29+
def test_basic(self):
30+
self.send_signal("Evaluation Results", self.res)
31+
self.widget.controls.display_rug.click()
32+
33+
def test_empty(self):
34+
res = copy.copy(self.res)
35+
res.row_indices = res.row_indices[:0]
36+
res.actual = res.actual[:0]
37+
res.predicted = res.predicted[:, 0]
38+
res.probabilities = res.probabilities[:, :0, :]
39+
self.send_signal("Evaluation Results", res)
40+
41+
def test_nan_input(self):
42+
res = copy.copy(self.res)
43+
res.actual = res.actual.copy()
44+
res.probabilities = res.probabilities.copy()
45+
46+
res.actual[0] = np.nan
47+
res.probabilities[:, [0, 3], :] = np.nan
48+
self.send_signal("Evaluation Results", res)
49+
self.assertTrue(self.widget.Error.invalid_results.is_shown())
50+
self.send_signal("Evaluation Results", None)
51+
self.assertFalse(self.widget.Error.invalid_results.is_shown())

Orange/widgets/evaluate/tests/test_owconfusionmatrix.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from Orange.classification import NaiveBayesLearner, TreeLearner
66
from Orange.regression import MeanLearner
77
from Orange.evaluation.testing import CrossValidation, TestOnTrainingData, \
8-
ShuffleSplit
8+
ShuffleSplit, Results
99
from Orange.widgets.evaluate.owconfusionmatrix import OWConfusionMatrix
1010
from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin
1111

@@ -82,3 +82,27 @@ def test_row_indices(self):
8282
correct_indices = results.row_indices[correct]
8383
self.assertSetEqual(set(self.iris[correct_indices].ids),
8484
set(selected.ids))
85+
86+
def test_empty_results(self):
87+
"""Test on empty results."""
88+
res = Results(data=self.iris[:0], store_data=True)
89+
res.row_indices = np.array([], dtype=int)
90+
res.actual = np.array([])
91+
res.predicted = np.array([[]])
92+
res.probabilities = np.zeros((1, 0, 3))
93+
self.send_signal("Evaluation Results", res)
94+
self.widget.select_correct()
95+
self.widget.select_wrong()
96+
97+
def test_nan_results(self):
98+
"""Test on results with nan values in actual/predicted"""
99+
res = Results(data=self.iris, nmethods=2, store_data=True)
100+
res.row_indices = np.array([0, 50, 100], dtype=int)
101+
res.actual = np.array([0., np.nan, 2.])
102+
res.predicted = np.array([[np.nan, 1, 2],
103+
[np.nan, np.nan, np.nan]])
104+
res.probabilities = np.zeros((1, 3, 3))
105+
self.send_signal("Evaluation Results", res)
106+
self.assertTrue(self.widget.Error.invalid_values.is_shown())
107+
self.send_signal("Evaluation Results", None)
108+
self.assertFalse(self.widget.Error.invalid_values.is_shown())
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import copy
2+
3+
import numpy as np
4+
5+
import Orange.data
6+
import Orange.evaluation
7+
import Orange.classification
8+
9+
from Orange.widgets.tests.base import WidgetTest
10+
from Orange.widgets.tests.utils import simulate
11+
from Orange.widgets.evaluate.owliftcurve import OWLiftCurve
12+
13+
14+
class TestOWLiftCurve(WidgetTest):
15+
@classmethod
16+
def setUpClass(cls):
17+
super().setUpClass()
18+
cls.lenses = data = Orange.data.Table("lenses")
19+
cls.res = Orange.evaluation.TestOnTestData(
20+
train_data=data[::2], test_data=data[1::2],
21+
learners=[Orange.classification.MajorityLearner(),
22+
Orange.classification.KNNLearner()],
23+
store_data=True,
24+
)
25+
26+
def setUp(self):
27+
super().setUp()
28+
self.widget = self.create_widget(
29+
OWLiftCurve,
30+
stored_settings={
31+
"display_convex_hull": True
32+
}
33+
) # type: OWLiftCurve
34+
35+
def test_basic(self):
36+
self.send_signal("Evaluation Results", self.res)
37+
simulate.combobox_run_through_all(self.widget.target_cb)
38+
39+
def test_empty_input(self):
40+
res = copy.copy(self.res)
41+
res.actual = res.actual[:0]
42+
res.row_indices = res.row_indices[:0]
43+
res.predicted = res.predicted[:, :0]
44+
res.probabilities = res.probabilities[:, :0, :]
45+
self.send_signal("Evaluation Results", res)
46+
47+
def test_nan_input(self):
48+
res = copy.copy(self.res)
49+
res.actual[0] = np.nan
50+
self.send_signal("Evaluation Results", res)
51+
self.assertTrue(self.widget.Error.invalid_results.is_shown())
52+
self.send_signal("Evaluation Results", None)
53+
self.assertFalse(self.widget.Error.invalid_results.is_shown())

0 commit comments

Comments
 (0)