Repository: biolab/orange3

Issue:
Show error by color in Predictions
<!-- 
Thanks for taking the time to submit a feature request!
For the best chance at our team considering your request, please answer the following questions to the best of your ability.
-->

**What's your use case?**
<!-- In other words, what's your pain point? -->
<!-- Is your request related to a problem, or perhaps a frustration? -->
<!-- Tell us the story that led you to write this request. -->
I want to observe regression predictions in Predictions widget. Currently, I only see numbers. It would be nice to have a visual signal in Predictions for how close the prediction is to the true value.




**What's your proposed solution?**
<!-- Be specific, clear, and concise. -->
Perhaps give each instance a line below, showing the error. Currently, classification problems display a line underneath with probabilities. For regression, the longer the line, the greater the error (would this be confusing, because for classification longer lines mean higher probability?).




**Are there any alternative solutions?**
/







Discussion:
I would expect that the line length would correspond to the prediction. Except, perhaps: we would put a tick in the middle of the cell and then a red line would extend from the tick to the left (or right), showing the negative (or positive) error.
For classification, we could emphasize the correct class (by making others more transparent)

Resolving patch:
diff --git a/Orange/widgets/evaluate/owpredictions.py b/Orange/widgets/evaluate/owpredictions.py
--- a/Orange/widgets/evaluate/owpredictions.py
+++ b/Orange/widgets/evaluate/owpredictions.py
@@ -1,3 +1,4 @@
+import warnings
 from contextlib import contextmanager
 from functools import partial
 from operator import itemgetter
@@ -8,9 +9,9 @@
 from AnyQt.QtWidgets import (
     QTableView, QSplitter, QToolTip, QStyle, QApplication, QSizePolicy,
     QPushButton)
-from AnyQt.QtGui import QPainter, QStandardItem, QPen, QColor
+from AnyQt.QtGui import QPainter, QStandardItem, QPen, QColor, QBrush
 from AnyQt.QtCore import (
-    Qt, QSize, QRect, QRectF, QPoint, QLocale,
+    Qt, QSize, QRect, QRectF, QPoint, QPointF, QLocale,
     QModelIndex, pyqtSignal, QTimer,
     QItemSelectionModel, QItemSelection)
 
@@ -367,11 +368,10 @@ def _update_scores(self):
                 continue
             row = [QStandardItem(learner_name(pred.predictor)),
                    QStandardItem("N/A"), QStandardItem("N/A")]
+            actual = results.actual
+            predicted = results.predicted
+            probabilities = results.probabilities
             try:
-                actual = results.actual
-                predicted = results.predicted
-                probabilities = results.probabilities
-
                 if self.class_var:
                     mask = numpy.isnan(results.actual)
                 else:
@@ -501,7 +501,10 @@ def _update_predictions_model(self):
             headers.append(p.predictor.name)
 
         if all_values:
-            model = PredictionsModel(all_values, all_probs, headers)
+            model = PredictionsModel(
+                all_values, all_probs,
+                self.data.Y if self.class_var else None,
+                headers)
             model.list_sorted.connect(
                 partial(
                     self._update_data_sort_order, self.predictionsview,
@@ -617,22 +620,37 @@ def _update_prediction_delegate(self):
                                                 - len(self.PROB_OPTS)]
             tooltip_probs = (shown_class, )
         sort_col_indices = []
-        for col, slot in enumerate(self.predictors):
+        if self.data \
+                and self.data.domain.class_var and not self.is_discrete_class:
+            with warnings.catch_warnings():
+                warnings.filterwarnings("ignore", ".*All-NaN.*", RuntimeWarning)
+                minv = numpy.nanmin(self.data.Y)
+                maxv = numpy.nanmax(self.data.Y)
+        else:
+            minv = maxv = numpy.nan
+        for col, slot in enumerate(self._non_errored_predictors()):
             target = slot.predictor.domain.class_var
             if target is not None and target.is_discrete:
                 shown_probs = self._shown_prob_indices(target, in_target=True)
                 if self.shown_probs in (self.MODEL_PROBS, self.BOTH_PROBS):
                     tooltip_probs = [self.class_values[i]
                                      for i in shown_probs if i is not None]
-                delegate = PredictionsItemDelegate(
+                delegate = ClassificationItemDelegate(
                     self.class_values, colors, shown_probs, tooltip_probs,
                     parent=self.predictionsview)
                 sort_col_indices.append([col for col in shown_probs
                                          if col is not None])
 
             else:
-                delegate = PredictionsItemDelegate(
-                    None, colors, (), (), target.format_str if target is not None else None,
+                predictions = slot.results.unmapped_predicted
+                with warnings.catch_warnings():
+                    warnings.filterwarnings("ignore", ".*All-NaN.*",
+                                            RuntimeWarning)
+                    minpv = numpy.nanmin([minv, numpy.nanmin(predictions)])
+                    maxpv = numpy.nanmax([maxv, numpy.nanmax(predictions)])
+                delegate = RegressionItemDelegate(
+                    target.format_str if target is not None else None,
+                    minpv, maxpv,
                     parent=self.predictionsview)
                 sort_col_indices.append(None)
 
@@ -861,62 +879,20 @@ class DataItemDelegate(ItemDelegate):
 
 class PredictionsItemDelegate(ItemDelegate):
     """
-    A Item Delegate for custom formatting of predictions/probabilities
+    A base Item Delegate for formatting and drawing predictions/probabilities
     """
     #: Roles supplied by the `PredictionsModel`
     DefaultRoles = (Qt.DisplayRole, )
 
-    def __init__(
-            self, class_values, colors, shown_probabilities=(),
-            tooltip_probabilities=(),
-            target_format=None, parent=None,
-    ):
+    def __init__(self, parent=None):
         super().__init__(parent)
-        self.class_values = class_values  # will be None for continuous
-        self.colors = [QColor(*c) for c in colors]
-        # target format for cont. vars
-        self.target_format = target_format if target_format else '%.2f'
-        self.shown_probabilities = self.fmt = self.tooltip = None  # set below
-        self.setFormat(shown_probabilities, tooltip_probabilities)
-
-    def setFormat(self, shown_probabilities=(), tooltip_probabilities=()):
-        self.shown_probabilities = shown_probabilities
-        if self.class_values is None:
-            # is continuous class
-            self.fmt = f"{{value:{self.target_format[1:]}}}"
-        else:
-            self.fmt = " \N{RIGHTWARDS ARROW} ".join(
-                [" : ".join(f"{{dist[{i}]:.2f}}" if i is not None else "-"
-                            for i in shown_probabilities)]
-                * bool(shown_probabilities)
-                + ["{value!s}"])
-        if tooltip_probabilities:
-            self.tooltip = f"p({', '.join(tooltip_probabilities)})"
-        else:
-            self.tooltip = ""
+        self.fmt = ""
 
     def displayText(self, value, _):
-        try:
-            value, dist = value
-        except ValueError:
+        if value is None:
             return ""
-        else:
-            return self.fmt.format(value=value, dist=dist)
-
-    def helpEvent(self, event, view, option, index):
-        if self.tooltip is not None:
-            # ... but can be an empty string, so the current tooltip is removed
-            QToolTip.showText(event.globalPos(), self.tooltip, view)
-            return True
-        else:
-            return super().helpEvent(event, view, option, index)
-
-    def initStyleOption(self, option, index):
-        super().initStyleOption(option, index)
-        if self.class_values is None:
-            option.displayAlignment = \
-                (option.displayAlignment & Qt.AlignVertical_Mask) | \
-                Qt.AlignRight
+        value, dist = value
+        return self.fmt.format(value=value, dist=dist)
 
     def sizeHint(self, option, index):
         # reimplemented
@@ -931,20 +907,7 @@ def sizeHint(self, option, index):
         height = sh.height() + metrics.leading() + 2 * margin
         return QSize(sh.width(), height)
 
-    def distribution(self, index):
-        value = self.cachedData(index, Qt.DisplayRole)
-        if isinstance(value, tuple) and len(value) == 2:
-            _, dist = value
-            return dist
-        else:
-            return None
-
     def paint(self, painter, option, index):
-        dist = self.distribution(index)
-        if dist is None or self.colors is None:
-            super().paint(painter, option, index)
-            return
-
         if option.widget is not None:
             style = option.widget.style()
         else:
@@ -983,38 +946,131 @@ def paint(self, painter, option, index):
             textrect.bottomLeft() + QPoint(0, spacing),
             QSize(rect.width(), distheight))
         painter.setPen(QPen(Qt.lightGray, 0.3))
-        self.drawDistBar(painter, distrect, dist)
+        self.drawBar(painter, option, index, distrect)
         painter.restore()
         if text:
             option.text = text
             self.drawViewItemText(style, painter, option, textrect)
 
-    def drawDistBar(self, painter, rect, distribution):
+    def drawBar(self, painter, option, index, rect):
+        pass  # pragma: no cover
+
+
+class ClassificationItemDelegate(PredictionsItemDelegate):
+    def __init__(
+            self, class_values, colors, shown_probabilities=(),
+            tooltip_probabilities=(), parent=None):
+        super().__init__(parent)
+        self.class_values = class_values
+        self.colors = [QColor(*c) for c in colors]
+
+        self.shown_probabilities = shown_probabilities
+        self.fmt = ""
+        if shown_probabilities:
+            probs = " : ".join(f"{{dist[{i}]:.2f}}" if i is not None else "-"
+                               for i in shown_probabilities)
+            self.fmt = f"{probs} → {{value!s}}"
+        else:
+            self.fmt = "{value!s}"
+
+        if tooltip_probabilities:
+            self.tooltip = f"p({', '.join(tooltip_probabilities)})"
+        else:
+            self.tooltip = ""
+
+    # pylint: disable=unused-argument
+    def helpEvent(self, event, view, option, index):
+        QToolTip.showText(event.globalPos(), self.tooltip, view)
+        return True
+
+    # pylint: disable=unused-argument
+    def drawBar(self, painter, option, index, rect):
+        value = self.cachedData(index, Qt.DisplayRole)
+        if not isinstance(value, tuple) or len(value) != 2:
+            return
+        _, distribution = value
+
         painter.save()
         painter.translate(rect.topLeft())
+        actual = index.data(Qt.UserRole)
         for i in self.shown_probabilities:
             if i is None:
                 continue
             dvalue = distribution[i]
             if not dvalue > 0:  # This also skips nans
                 continue
-            painter.setBrush(self.colors[i])
             width = rect.width() * dvalue
-            painter.drawRoundedRect(QRectF(0, 0, width, rect.height()), 1, 2)
+            height = rect.height()
+            painter.setBrush(self.colors[i])
+            if i == actual:
+                painter.drawRoundedRect(QRectF(0, 0, width, height), 1, 2)
+            else:
+                painter.drawRoundedRect(
+                    QRectF(0, height / 4, width, height / 2), 1, 2)
             painter.translate(width, 0.0)
         painter.restore()
 
 
+class RegressionItemDelegate(PredictionsItemDelegate):
+    def __init__(self,
+                 target_format: Optional[str]=None,
+                 minv: Optional[float]=None, maxv: Optional[float]=None,
+                 parent=None):
+        super().__init__(parent)
+        self.fmt = f"{{value:{(target_format or '%.2f')[1:]}}}"
+        assert (minv is None) is (maxv is None)
+        assert (not isinstance(minv, float) or numpy.isnan(float(minv))) \
+               is (not isinstance(maxv, float) or numpy.isnan(float(maxv)))
+        if minv is None or numpy.isnan(minv):
+            self.offset = 0
+            self.span = 1
+        else:
+            self.offset = minv
+            self.span = maxv - minv or 1
+
+    def initStyleOption(self, option, index):
+        super().initStyleOption(option, index)
+        option.displayAlignment = \
+            (option.displayAlignment & Qt.AlignVertical_Mask) | \
+            Qt.AlignRight
+
+    def drawBar(self, painter, option, index, rect):
+        value = self.cachedData(index, Qt.DisplayRole)
+        if not isinstance(value, tuple) or len(value) != 2:
+            return
+        value, _ = value
+
+        width = rect.width()
+        height = rect.height()
+        xactual = (index.data(Qt.UserRole) - self.offset) / self.span * width
+        xvalue = (value - self.offset) / self.span * width
+
+        painter.save()
+        painter.translate(rect.topLeft())
+        if numpy.isfinite(xvalue):
+            painter.setBrush(QBrush(Qt.magenta))
+            painter.drawRect(QRectF(0, 0, xvalue, height))
+        if numpy.isfinite(xactual):
+            painter.setPen(QPen(QBrush(Qt.black), 1))
+            painter.setBrush(Qt.white)
+            painter.drawEllipse(QPointF(xactual, height / 2), 1.5, 1.5)
+        painter.restore()
+
+
 class PredictionsModel(AbstractSortTableModel):
     list_sorted = pyqtSignal()
 
-    def __init__(self, values=None, probs=None, headers=None, parent=None):
+    def __init__(self, values=None, probs=None, actual=None,
+                 headers=None, parent=None):
         super().__init__(parent)
         self._values = values
         self._probs = probs
+        self._actual = actual
         self.__probInd = None
         if values is not None:
-            assert len(self._values) == len(self._probs) != 0
+            assert len(values) == len(probs) != 0
+            assert len(values[0]) == len(probs[0])
+            assert actual is None or len(probs[0]) == len(actual)
             sizes = {len(x) for c in (values, probs) for x in c}
             assert len(sizes) == 1
             self.__columnCount = len(values)
@@ -1034,10 +1090,12 @@ def columnCount(self, parent=QModelIndex()):
         return 0 if parent.isValid() else self.__columnCount
 
     def data(self, index, role=Qt.DisplayRole):
+        row = self.mapToSourceRows(index.row())
         if role in (Qt.DisplayRole, Qt.EditRole):
             column = index.column()
-            row = self.mapToSourceRows(index.row())
             return self._values[column][row], self._probs[column][row]
+        if role == Qt.UserRole:
+            return self._actual[row] if self._actual is not None else numpy.nan
         return None
 
     def headerData(self, section, orientation, role=Qt.DisplayRole):


Test patch:
diff --git a/Orange/widgets/evaluate/tests/test_owpredictions.py b/Orange/widgets/evaluate/tests/test_owpredictions.py
--- a/Orange/widgets/evaluate/tests/test_owpredictions.py
+++ b/Orange/widgets/evaluate/tests/test_owpredictions.py
@@ -6,7 +6,8 @@
 
 import numpy as np
 
-from AnyQt.QtCore import QItemSelectionModel, QItemSelection, Qt
+from AnyQt.QtCore import QItemSelectionModel, QItemSelection, Qt, QRect
+from AnyQt.QtWidgets import QToolTip
 
 from Orange.base import Model
 from Orange.classification import LogisticRegressionLearner, NaiveBayesLearner
@@ -15,10 +16,11 @@
 from Orange.evaluation.scoring import TargetScore
 from Orange.preprocess import Remove
 from Orange.regression import LinearRegressionLearner, MeanLearner
-from Orange.widgets.tests.base import WidgetTest
+from Orange.widgets.tests.base import WidgetTest, GuiTest
 from Orange.widgets.evaluate.owpredictions import (
     OWPredictions, SharedSelectionModel, SharedSelectionStore, DataModel,
-    PredictionsModel)
+    PredictionsModel,
+    PredictionsItemDelegate, ClassificationItemDelegate, RegressionItemDelegate)
 from Orange.widgets.evaluate.owcalibrationplot import OWCalibrationPlot
 from Orange.widgets.evaluate.owconfusionmatrix import OWConfusionMatrix
 from Orange.widgets.evaluate.owliftcurve import OWLiftCurve
@@ -137,6 +139,40 @@ def test_no_class_on_test(self):
         out = self.get_output(self.widget.Outputs.predictions)
         np.testing.assert_allclose(out.get_column_view("constant")[0], 0)
 
+        predmodel = self.widget.predictionsview.model()
+        self.assertTrue(np.isnan(
+            predmodel.data(predmodel.index(0, 0), Qt.UserRole)))
+        self.assertIn(predmodel.data(predmodel.index(0, 0))[0],
+                      titanic.domain.class_var.values)
+
+    def test_invalid_regression_target(self):
+        widget = self.widget
+        self.send_signal(widget.Inputs.predictors,
+                         LinearRegressionLearner()(self.housing), 0)
+
+        dom = self.housing.domain
+        wrong_class = self.iris.transform(Domain(dom.attributes[:-1],
+                                                 dom.attributes[-1]))
+        self.send_signal(widget.Inputs.data, wrong_class)
+
+        # can't make a prediction
+        predmodel = self.widget.predictionsview.model()
+        self.assertTrue(np.isnan(
+            predmodel.data(predmodel.index(0, 0), Qt.UserRole)))
+        # ... but model reports a value
+        self.assertFalse(np.isnan(predmodel.data(predmodel.index(0, 0))[0]))
+
+        no_class = self.iris.transform(Domain(dom.attributes[:-1],
+                                              dom.attributes[-1]))
+        self.send_signal(widget.Inputs.data, no_class)
+
+        # can't make a prediction
+        predmodel = self.widget.predictionsview.model()
+        self.assertTrue(np.isnan(
+            predmodel.data(predmodel.index(0, 0), Qt.UserRole)))
+        # ... but model reports a value
+        self.assertFalse(np.isnan(predmodel.data(predmodel.index(0, 0))[0]))
+
     def test_bad_data(self):
         """
         Firstly it creates predictions with TreeLearner. Then sends predictions and
@@ -610,6 +646,7 @@ def test_update_prediction_delegate_discrete(self):
         widget = self.widget
         prob_combo = widget.controls.shown_probs
         set_prob_ind = widget.predictionsview.model().setProbInd
+        widget._non_errored_predictors = lambda: widget.predictors[:4]
 
         widget.data = Table.from_list(
             Domain([], DiscreteVariable("c", values=tuple("abc"))), [])
@@ -628,9 +665,10 @@ def test_update_prediction_delegate_discrete(self):
         widget.shown_probs = widget.NO_PROBS
         widget._update_prediction_delegate()
         for delegate in widget._delegates:
-            self.assertEqual(list(delegate.shown_probabilities), [])
-            self.assertEqual(delegate.tooltip, "")
-        set_prob_ind.assert_called_with([[], [], [], [], None])
+            if isinstance(delegate, ClassificationItemDelegate):
+                self.assertEqual(list(delegate.shown_probabilities), [])
+                self.assertEqual(delegate.tooltip, "")
+        set_prob_ind.assert_called_with([[], [], [], []])
 
         widget.shown_probs = widget.DATA_PROBS
         widget._update_prediction_delegate()
@@ -639,11 +677,9 @@ def test_update_prediction_delegate_discrete(self):
         self.assertEqual(widget._delegates[1].shown_probabilities, [0, 1, None])
         self.assertEqual(widget._delegates[2].shown_probabilities, [None, 1, 2])
         self.assertEqual(widget._delegates[3].shown_probabilities, [None, None, None])
-        self.assertEqual(widget._delegates[4].shown_probabilities, ())
-        self.assertEqual(widget._delegates[4].tooltip, "")
         for delegate in widget._delegates[:-1]:
             self.assertEqual(delegate.tooltip, "p(a, b, c)")
-        set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [1, 2], [], None])
+        set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [1, 2], []])
 
         widget.shown_probs = widget.MODEL_PROBS
         widget._update_prediction_delegate()
@@ -655,9 +691,7 @@ def test_update_prediction_delegate_discrete(self):
         self.assertEqual(widget._delegates[2].tooltip, "p(c, b, d)")
         self.assertEqual(widget._delegates[3].shown_probabilities, [4])
         self.assertEqual(widget._delegates[3].tooltip, "p(e)")
-        self.assertEqual(widget._delegates[4].shown_probabilities, ())
-        self.assertEqual(widget._delegates[4].tooltip, "")
-        set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [2, 1, 3], [4], None])
+        set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [2, 1, 3], [4]])
 
         widget.shown_probs = widget.BOTH_PROBS
         widget._update_prediction_delegate()
@@ -669,9 +703,7 @@ def test_update_prediction_delegate_discrete(self):
         self.assertEqual(widget._delegates[2].tooltip, "p(b, c)")
         self.assertEqual(widget._delegates[3].shown_probabilities, [])
         self.assertEqual(widget._delegates[3].tooltip, "")
-        self.assertEqual(widget._delegates[4].shown_probabilities, ())
-        self.assertEqual(widget._delegates[4].tooltip, "")
-        set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [1, 2], [], None])
+        set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [1, 2], []])
 
         n_fixed = len(widget.PROB_OPTS)
         widget.shown_probs = n_fixed  # a
@@ -680,10 +712,9 @@ def test_update_prediction_delegate_discrete(self):
         self.assertEqual(widget._delegates[1].shown_probabilities, [0])
         self.assertEqual(widget._delegates[2].shown_probabilities, [None])
         self.assertEqual(widget._delegates[3].shown_probabilities, [None])
-        self.assertEqual(widget._delegates[4].shown_probabilities, ())
         for delegate in widget._delegates[:-1]:
             self.assertEqual(delegate.tooltip, "p(a)")
-        set_prob_ind.assert_called_with([[0], [0], [], [], None])
+        set_prob_ind.assert_called_with([[0], [0], [], []])
 
         n_fixed = len(widget.PROB_OPTS)
         widget.shown_probs = n_fixed + 1  # b
@@ -692,10 +723,9 @@ def test_update_prediction_delegate_discrete(self):
         self.assertEqual(widget._delegates[1].shown_probabilities, [1])
         self.assertEqual(widget._delegates[2].shown_probabilities, [1])
         self.assertEqual(widget._delegates[3].shown_probabilities, [None])
-        self.assertEqual(widget._delegates[4].shown_probabilities, ())
         for delegate in widget._delegates[:-1]:
             self.assertEqual(delegate.tooltip, "p(b)")
-        set_prob_ind.assert_called_with([[1], [1], [1], [], None])
+        set_prob_ind.assert_called_with([[1], [1], [1], []])
 
         n_fixed = len(widget.PROB_OPTS)
         widget.shown_probs = n_fixed + 2  # c
@@ -704,17 +734,15 @@ def test_update_prediction_delegate_discrete(self):
         self.assertEqual(widget._delegates[1].shown_probabilities, [None])
         self.assertEqual(widget._delegates[2].shown_probabilities, [2])
         self.assertEqual(widget._delegates[3].shown_probabilities, [None])
-        self.assertEqual(widget._delegates[4].shown_probabilities, ())
         for delegate in widget._delegates[:-1]:
             self.assertEqual(delegate.tooltip, "p(c)")
-        set_prob_ind.assert_called_with([[2], [], [2], [], None])
+        set_prob_ind.assert_called_with([[2], [], [2], []])
 
     def test_update_delegates_continuous(self):
         self._mock_predictors()
 
         widget = self.widget
         widget.shown_probs = widget.DATA_PROBS
-        set_prob_ind = widget.predictionsview.model().setProbInd
 
         widget.data = Table.from_list(Domain([], ContinuousVariable("c")), [])
 
@@ -728,11 +756,66 @@ def test_update_delegates_continuous(self):
         widget._set_target_combos()
         self.assertEqual(widget.shown_probs, widget.NO_PROBS)
 
-        widget._update_prediction_delegate()
-        for delegate in widget._delegates:
-            self.assertEqual(list(delegate.shown_probabilities), [])
-            self.assertEqual(delegate.tooltip, "")
-        set_prob_ind.assert_called_with([[], [], [], [], None])
+    def test_delegate_ranges(self):
+        widget = self.widget
+
+        class Model1(Model):
+            name = "foo"
+
+            def predict(self, X):
+                return X[:, 0] - 2
+
+        class Model2(Model):
+            name = "bar"
+
+            def predict(self, X):
+                return np.full(len(X), np.nan)
+
+        domain = Domain([ContinuousVariable("x")], ContinuousVariable("y"))
+        x = np.arange(12, 17, dtype=float)[:, None]
+        y = np.array([12, 13, 14, 15, np.nan])
+        data = Table(domain, x, y)
+
+        ddomain = Domain(
+            [ContinuousVariable("x")],
+            DiscreteVariable("y", values=tuple("abcdefghijklmnopq")))
+        self.send_signal(widget.Inputs.data, data)
+        self.send_signal(widget.Inputs.predictors, Model1(domain), 1)
+        self.send_signal(widget.Inputs.predictors, Model2(domain), 2)
+        self.send_signal(widget.Inputs.predictors, Model1(ddomain), 3)
+
+        delegate = widget.predictionsview.itemDelegateForColumn(0)
+        # values for model are 10 to 14 (incl), Y goes from 12 to 15 (incl)
+        self.assertIsInstance(delegate, RegressionItemDelegate)
+        self.assertEqual(delegate.offset, 10)
+        self.assertEqual(delegate.span, 5)
+
+        delegate = widget.predictionsview.itemDelegateForColumn(1)
+        # values for model are all-nan, Y goes from 12 to 15 (incl)
+        self.assertIsInstance(delegate, RegressionItemDelegate)
+        self.assertEqual(delegate.offset, 12)
+        self.assertEqual(delegate.span, 3)
+
+        delegate = widget.predictionsview.itemDelegateForColumn(2)
+        self.assertIsInstance(delegate, ClassificationItemDelegate)
+
+        data = Table(domain, x, np.full(5, np.nan))
+        self.send_signal(widget.Inputs.data, data)
+        delegate = widget.predictionsview.itemDelegateForColumn(0)
+        # values for model are 10 to 14 (incl), Y is nan
+        self.assertIsInstance(delegate, RegressionItemDelegate)
+        self.assertEqual(delegate.offset, 10)
+        self.assertEqual(delegate.span, 4)
+
+        delegate = widget.predictionsview.itemDelegateForColumn(1)
+        # values for model and y are nan
+        self.assertIsInstance(delegate, RegressionItemDelegate)
+        self.assertEqual(delegate.offset, 0)
+        self.assertEqual(delegate.span, 1)
+
+        delegate = widget.predictionsview.itemDelegateForColumn(2)
+        self.assertIsInstance(delegate, ClassificationItemDelegate)
+
 
     class _Scorer(TargetScore):
         # pylint: disable=arguments-differ
@@ -1150,6 +1233,7 @@ class PredictionsModelTest(unittest.TestCase):
     @classmethod
     def setUpClass(cls) -> None:
         cls.values = np.array([[0, 1, 1, 2, 0], [0, 0, 0, 1, 0]], dtype=float)
+        cls.actual = np.array([0, 1, 2, 1, 0], dtype=float)
         cls.probs = [np.array([[80, 10, 10],
                                [30, 70, 0],
                                [15, 80, 5],
@@ -1163,7 +1247,7 @@ def setUpClass(cls) -> None:
         cls.no_probs = [np.zeros((5, 0)), np.zeros((5, 0))]
 
     def test_model_classification(self):
-        model = PredictionsModel(self.values, self.probs)
+        model = PredictionsModel(self.values, self.probs, self.actual)
         self.assertEqual(model.rowCount(), 5)
         self.assertEqual(model.columnCount(), 2)
 
@@ -1176,7 +1260,7 @@ def test_model_classification(self):
         np.testing.assert_equal(prob, [0.1, 0.6, 0.3])
 
     def test_model_regression(self):
-        model = PredictionsModel(self.values, self.no_probs)
+        model = PredictionsModel(self.values, self.no_probs, self.actual)
         self.assertEqual(model.rowCount(), 5)
         self.assertEqual(model.columnCount(), 2)
 
@@ -1188,17 +1272,27 @@ def test_model_regression(self):
         self.assertEqual(val, 1)
         np.testing.assert_equal(prob, [])
 
+    def test_model_actual(self):
+        model = PredictionsModel(self.values, self.no_probs, self.actual)
+        self.assertEqual(model.data(model.index(2, 0), Qt.UserRole),
+                         self.actual[2])
+
+    def test_model_no_actual(self):
+        model = PredictionsModel(self.values, self.no_probs, None)
+        self.assertTrue(np.isnan(model.data(model.index(2, 0), Qt.UserRole)),
+                         self.actual[2])
+
     def test_model_header(self):
-        model = PredictionsModel(self.values, self.probs)
+        model = PredictionsModel(self.values, self.probs, self.actual)
         self.assertIsNone(model.headerData(0, Qt.Horizontal))
         self.assertEqual(model.headerData(3, Qt.Vertical), "4")
 
-        model = PredictionsModel(self.values, self.probs, ["a", "b"])
+        model = PredictionsModel(self.values, self.probs, self.actual, ["a", "b"])
         self.assertEqual(model.headerData(0, Qt.Horizontal), "a")
         self.assertEqual(model.headerData(1, Qt.Horizontal), "b")
         self.assertEqual(model.headerData(3, Qt.Vertical), "4")
 
-        model = PredictionsModel(self.values, self.probs, ["a"])
+        model = PredictionsModel(self.values, self.probs, self.actual, ["a"])
         self.assertEqual(model.headerData(0, Qt.Horizontal), "a")
         self.assertIsNone(model.headerData(1, Qt.Horizontal))
         self.assertEqual(model.headerData(3, Qt.Vertical), "4")
@@ -1210,7 +1304,7 @@ def test_model_empty(self):
         self.assertIsNone(model.headerData(1, Qt.Horizontal))
 
     def test_sorting_classification(self):
-        model = PredictionsModel(self.values, self.probs)
+        model = PredictionsModel(self.values, self.probs, self.actual)
 
         val, prob = model.data(model.index(0, 1))
         self.assertEqual(val, 0)
@@ -1259,7 +1353,7 @@ def test_sorting_classification(self):
                           for i in range(model.rowCount())], [2, 1, 1, 0, 0])
 
     def test_sorting_classification_different(self):
-        model = PredictionsModel(self.values, self.probs)
+        model = PredictionsModel(self.values, self.probs, self.actual)
 
         model.setProbInd([[2], [0]])
         model.sort(0, Qt.DescendingOrder)
@@ -1278,7 +1372,7 @@ def test_sorting_classification_different(self):
         np.testing.assert_equal(prob, [0.9, 0.05, 0.05])
 
     def test_sorting_regression(self):
-        model = PredictionsModel(self.values, self.no_probs)
+        model = PredictionsModel(self.values, self.no_probs, self.actual)
 
         self.assertEqual(model.data(model.index(0, 1))[0], 0)
         self.assertEqual(model.data(model.index(3, 1))[0], 1)
@@ -1299,5 +1393,153 @@ def test_sorting_regression(self):
                           for i in range(model.rowCount())], [0, 0, 1, 1, 2])
 
 
+class TestPredictionsItemDelegate(GuiTest):
+    def test_displayText(self):
+        delegate = PredictionsItemDelegate()
+        delegate.fmt = "{value:.3f}"
+        self.assertEqual(delegate.displayText((0.12345, [1, 2, 3]), Mock()), "0.123")
+        delegate.fmt = "{value:.1f}"
+        self.assertEqual(delegate.displayText((0.12345, [1, 2, 3]), Mock()), "0.1")
+        delegate.fmt = "{value:.1f} - {dist[2]}"
+        self.assertEqual(delegate.displayText((0.12345, [1, 2, 3]), Mock()), "0.1 - 3")
+
+        self.assertEqual(delegate.displayText(None, Mock()), "")
+
+
+class TestClassificationItemDelegate(GuiTest):
+    @patch.object(QToolTip, "showText")
+    def test_format(self, showText):
+        delegate = ClassificationItemDelegate(["foo", "bar", "baz"],
+                                              [(1, 2, 3), (4, 5, 6), (7, 8, 9)],
+                                              (1, None, 0), ("foo", "baz")
+                                              )
+        delegate.helpEvent(Mock(), Mock(), Mock(), Mock())
+        self.assertEqual(showText.call_args[0][1], "p(foo, baz)")
+        self.assertEqual(delegate.displayText((["baz", (0.4, 0.6)]), Mock()),
+                         "0.60 : - : 0.40 → baz")
+        showText.reset_mock()
+
+        delegate = ClassificationItemDelegate(["foo", "bar", "baz"],
+                                              [(1, 2, 3), (4, 5, 6), (7, 8, 9)],
+                                              )
+        delegate.helpEvent(Mock(), Mock(), Mock(), Mock())
+        self.assertEqual(showText.call_args[0][1], "")
+        self.assertEqual(delegate.displayText((["baz", (0.4, 0.6)]), Mock()),
+                         "baz")
+
+    def test_drawbar(self):
+        delegate = ClassificationItemDelegate(
+            ["foo", "bar", "baz", "bax"],
+            [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)],
+            (1, None, 0, 2), ("baz", "foo", "bax"))
+        painter = Mock()
+        rr = painter.drawRoundedRect
+        index = Mock()
+        index.data = lambda *_: 2
+        rect = QRect(0, 0, 256, 16)
+
+        delegate.cachedData = lambda *_: None
+        delegate.drawBar(painter, Mock(), index, rect)
+        rr.assert_not_called()
+
+        delegate.cachedData = lambda *_: (1, (0.25, 0, 0.75, 0))
+        delegate.drawBar(painter, Mock(), index, rect)
+        self.assertEqual(rr.call_count, 2)
+        rect = rr.call_args_list[0][0][0]
+        self.assertEqual(rect.width(), 64)
+        self.assertEqual(rect.height(), 8)
+        rect = rr.call_args_list[1][0][0]
+        self.assertEqual(rect.width(), 192)
+        self.assertEqual(rect.height(), 16)
+
+
+class TestRegressionItemDelegate(GuiTest):
+    def test_format(self):
+        delegate = RegressionItemDelegate("%6.3f")
+        self.assertEqual(delegate.displayText((5.13, None), Mock()), " 5.130")
+        self.assertEqual(delegate.offset, 0)
+        self.assertEqual(delegate.span, 1)
+
+        delegate = RegressionItemDelegate("%6.3f", 2, 5)
+        self.assertEqual(delegate.displayText((5.13, None), Mock()), " 5.130")
+        self.assertEqual(delegate.offset, 2)
+        self.assertEqual(delegate.span, 3)
+
+        delegate = RegressionItemDelegate(None, 2, 5)
+        self.assertEqual(delegate.displayText((5.1, None), Mock()), "5.10")
+        self.assertEqual(delegate.offset, 2)
+        self.assertEqual(delegate.span, 3)
+
+    def test_drawBar(self):
+        delegate = RegressionItemDelegate("%6.3f", 2, 10)
+        painter = Mock()
+        dr = painter.drawRect
+        el = painter.drawEllipse
+        index = Mock()
+        rect = QRect(0, 0, 256, 16)
+
+        ### Actual is missing
+        index.data = lambda *_: np.nan
+
+        # Prediction is missing
+        delegate.cachedData = lambda *_: None
+        delegate.drawBar(painter, Mock(), index, rect)
+
+        dr.assert_not_called()
+        el.assert_not_called()
+
+        # Prediction is known
+        delegate.cachedData = lambda *_: (8.0, None)
+        delegate.drawBar(painter, Mock(), index, rect)
+
+        dr.assert_called_once()
+        rrect = dr.call_args[0][0]
+        self.assertEqual(rrect.width(), 192)
+        el.assert_not_called()
+        dr.reset_mock()
+
+        ### Actual is known
+        index.data = lambda *_: 8.0
+
+        # Prediction is correct
+        delegate.cachedData = lambda *_: (8.0, None)
+        delegate.drawBar(painter, Mock(), index, rect)
+
+        dr.assert_called_once()
+        rrect = dr.call_args[0][0]
+        self.assertEqual(rrect.width(), 192)
+        el.assert_called_once()
+        center = el.call_args[0][0]
+        self.assertEqual(center.x(), 192)
+        dr.reset_mock()
+        el.reset_mock()
+
+        # Prediction is below
+        delegate.cachedData = lambda *_: (6.0, None)
+        delegate.drawBar(painter, Mock(), index, rect)
+
+        dr.assert_called_once()
+        rrect = dr.call_args[0][0]
+        self.assertEqual(rrect.width(), 128)
+        el.assert_called_once()
+        center = el.call_args[0][0]
+        self.assertEqual(center.x(), 192)
+        dr.reset_mock()
+        el.reset_mock()
+
+        # Prediction is above
+        delegate.cachedData = lambda *_: (9.0, None)
+        delegate.drawBar(painter, Mock(), index, rect)
+
+        dr.assert_called_once()
+        rrect = dr.call_args[0][0]
+        self.assertEqual(rrect.width(), 224)
+        el.assert_called_once()
+        center = el.call_args[0][0]
+        self.assertEqual(center.x(), 192)
+        dr.reset_mock()
+        el.reset_mock()
+
+
 if __name__ == "__main__":
     unittest.main()