Repository: biolab/orange3

Issue:
Bug: Test and Score Evaluation results sort alphabetically
<!--
This is an issue template. Please fill in the relevant details in the
sections below.

Wrap code and verbatim terminal window output into triple backticks, see:
https://help.github.com/articles/basic-writing-and-formatting-syntax/#quoting-code

If you're raising an issue about an add-on (e.g. installed via
Options > Add-ons), raise an issue on the relevant add-on's issue
tracker instead. See: https://github.com/biolab?q=orange3
-->

##### Orange version
<!-- From menu _Help→About→Version_ or code `Orange.version.full_version` -->
3.22

##### Expected behavior
In the Test&Score module window, there are five columns by default: Model, MSE, RMSE, MAE & R2.  All but the first column contains numeric values.  It is possible to click on any of the column headers to sort the table by that column.  Expected behavior:  sorting by the numeric columns will use numeric sort behavior.


##### Actual behavior
Clicking on a numeric column header sorts with alphabetic/lexicographic behavior, so a possible "sorted" list might look like:
16992
178
355
4536


##### Steps to reproduce the behavior
1. Associate multiple model modules with a Test and Score module
2. Once the Test and Score module results window
3. Click on a column header to sort by a numeric column.

In the attached screenshot, the table is sorted by column 3, RMSE, alphabetically ascending:
12630
1389
20510
2194779950371
37742
<img width="823" alt="screenshot_200" src="https://user-images.githubusercontent.com/24887253/62026009-9c769400-b18e-11e9-87b5-fae996ac35af.png">

For sorting to be useful, these values should be sorted:

1389
12630
20510
37742
2194779950371

##### Additional info (worksheets, data, screenshots, ...)





Discussion:
Thanks! Funny that nobody noticed this before. Fixed in #3951.

@ajdapretnar, other widgets (such as GEO Data Sets) can be fixed in a similar way, but not by reusing the code from #3951. Test & Score is specific because we want to have strings and Nones (both representing errors) always at the bottom of the table. Most other widgets should expect just numbers and empty cells, which can be treated as `-np.inf`.
> @ajdapretnar, other widgets (such as GEO Data Sets) can be fixed in a similar way, but not by reusing the code from #3595 

Gotcha! 👌 
~Or maybe just undo b4196790548a57ff4de9e52c2a7f87a4b9c0ad4f and go on from there.~

<details>
<summary>...</summary>

```diff
diff --git a/Orange/widgets/evaluate/owtestlearners.py b/Orange/widgets/evaluate/owtestlearners.py
index 139f2cb9c..9a2d77de9 100644
--- a/Orange/widgets/evaluate/owtestlearners.py
+++ b/Orange/widgets/evaluate/owtestlearners.py
@@ -533,7 +533,7 @@ class OWTestLearners(OWWidget):
                 for stat, scorer in zip(stats, self.scorers):
                     item = QStandardItem()
                     if stat.success:
-                        item.setText("{:.3f}".format(stat.value[0]))
+                        item.setData(float(stat.value[0]), Qt.DisplayRole)
                     else:
                         item.setToolTip(str(stat.exception))
                         if scorer.name in self.score_table.shown_scores:
diff --git a/Orange/widgets/evaluate/utils.py b/Orange/widgets/evaluate/utils.py
index ebe060327..6caa6811b 100644
--- a/Orange/widgets/evaluate/utils.py
+++ b/Orange/widgets/evaluate/utils.py
@@ -109,6 +109,12 @@ class ScoreTable(OWComponent, QObject):
             size = super().sizeHint(*args)
             return QSize(size.width(), size.height() + 6)
 
+        def displayText(self, value, locale):
+            if isinstance(value, float):
+                return "{:.3f}".format(value)
+            else:
+                return super().displayText(value, locale)
+
     def __init__(self, master):
         QObject.__init__(self)
         OWComponent.__init__(self, master)
```

</details>

EDIT: posted in the wrong thread.

Resolving patch:
diff --git a/Orange/widgets/evaluate/owtestlearners.py b/Orange/widgets/evaluate/owtestlearners.py
--- a/Orange/widgets/evaluate/owtestlearners.py
+++ b/Orange/widgets/evaluate/owtestlearners.py
@@ -533,7 +533,7 @@ def update_stats_model(self):
                 for stat, scorer in zip(stats, self.scorers):
                     item = QStandardItem()
                     if stat.success:
-                        item.setText("{:.3f}".format(stat.value[0]))
+                        item.setData(float(stat.value[0]), Qt.DisplayRole)
                     else:
                         item.setToolTip(str(stat.exception))
                         if scorer.name in self.score_table.shown_scores:
diff --git a/Orange/widgets/evaluate/utils.py b/Orange/widgets/evaluate/utils.py
--- a/Orange/widgets/evaluate/utils.py
+++ b/Orange/widgets/evaluate/utils.py
@@ -6,7 +6,8 @@
 
 from AnyQt.QtWidgets import QHeaderView, QStyledItemDelegate, QMenu
 from AnyQt.QtGui import QStandardItemModel, QStandardItem
-from AnyQt.QtCore import Qt, QSize, QObject, pyqtSignal as Signal
+from AnyQt.QtCore import Qt, QSize, QObject, pyqtSignal as Signal, \
+    QSortFilterProxyModel
 from sklearn.exceptions import UndefinedMetricWarning
 
 from Orange.data import Variable, DiscreteVariable, ContinuousVariable
@@ -98,6 +99,32 @@ def thunked():
     return thunked
 
 
+class ScoreModel(QSortFilterProxyModel):
+    def lessThan(self, left, right):
+        def is_bad(x):
+            return not isinstance(x, (int, float, str)) \
+                   or isinstance(x, float) and np.isnan(x)
+
+        left = left.data()
+        right = right.data()
+        is_ascending = self.sortOrder() == Qt.AscendingOrder
+
+        # bad entries go below; if both are bad, left remains above
+        if is_bad(left) or is_bad(right):
+            return is_bad(right) == is_ascending
+
+        # for data of different types, numbers are at the top
+        if type(left) is not type(right):
+            return isinstance(left, float) == is_ascending
+
+        # case insensitive comparison for strings
+        if isinstance(left, str):
+            return left.upper() < right.upper()
+
+        # otherwise, compare numbers
+        return left < right
+
+
 class ScoreTable(OWComponent, QObject):
     shown_scores = \
         Setting(set(chain(*BUILTIN_SCORERS_ORDER.values())))
@@ -109,6 +136,12 @@ def sizeHint(self, *args):
             size = super().sizeHint(*args)
             return QSize(size.width(), size.height() + 6)
 
+        def displayText(self, value, locale):
+            if isinstance(value, float):
+                return f"{value:.3f}"
+            else:
+                return super().displayText(value, locale)
+
     def __init__(self, master):
         QObject.__init__(self)
         OWComponent.__init__(self, master)
@@ -125,7 +158,9 @@ def __init__(self, master):
 
         self.model = QStandardItemModel(master)
         self.model.setHorizontalHeaderLabels(["Method"])
-        self.view.setModel(self.model)
+        self.sorted_model = ScoreModel()
+        self.sorted_model.setSourceModel(self.model)
+        self.view.setModel(self.sorted_model)
         self.view.setItemDelegate(self.ItemDelegate())
 
     def _column_names(self):


Test patch:
diff --git a/Orange/widgets/evaluate/tests/test_owtestlearners.py b/Orange/widgets/evaluate/tests/test_owtestlearners.py
--- a/Orange/widgets/evaluate/tests/test_owtestlearners.py
+++ b/Orange/widgets/evaluate/tests/test_owtestlearners.py
@@ -277,10 +277,10 @@ def __call__(self, data):
         # Ensure that the click on header caused an ascending sort
         # Ascending sort means that wrong model should be listed first
         self.assertEqual(header.sortIndicatorOrder(), Qt.AscendingOrder)
-        self.assertEqual(view.model().item(0, 0).text(), "VersicolorLearner")
+        self.assertEqual(view.model().index(0, 0).data(), "VersicolorLearner")
 
         self.send_signal(self.widget.Inputs.test_data, versicolor, wait=5000)
-        self.assertEqual(view.model().item(0, 0).text(), "SetosaLearner")
+        self.assertEqual(view.model().index(0, 0).data(), "SetosaLearner")
 
         self.widget.hide()
 
@@ -365,10 +365,11 @@ def test_scores_log_reg_advanced(self):
                 [1, 1, 1.23, 23.8, 5.], [1., 2., 3., 4., 3.], "yynnn"))
         )
 
-        self.assertTupleEqual(self._test_scores(
-            table_train, table_test, LogisticRegressionLearner(),
-            OWTestLearners.TestOnTest, None),
-                              (0.667, 0.8, 0.8, 0.867, 0.8))
+        np.testing.assert_almost_equal(
+            self._test_scores(table_train, table_test,
+                              LogisticRegressionLearner(),
+                              OWTestLearners.TestOnTest, None),
+            (2 / 3, 0.8, 0.8, 13 / 15, 0.8))
 
     def test_scores_cross_validation(self):
         """
diff --git a/Orange/widgets/evaluate/tests/test_utils.py b/Orange/widgets/evaluate/tests/test_utils.py
--- a/Orange/widgets/evaluate/tests/test_utils.py
+++ b/Orange/widgets/evaluate/tests/test_utils.py
@@ -3,8 +3,11 @@
 import unittest
 import collections
 
+import numpy as np
+
 from AnyQt.QtWidgets import QMenu
-from AnyQt.QtCore import QPoint
+from AnyQt.QtGui import QStandardItem
+from AnyQt.QtCore import QPoint, Qt
 
 from Orange.widgets.evaluate.utils import ScoreTable
 from Orange.widgets.tests.base import GuiTest
@@ -70,5 +73,48 @@ def test_update_shown_columns(self):
                              not header.isSectionHidden(i),
                              msg="error in section {}({})".format(i, name))
 
+    def test_sorting(self):
+        def order(n=5):
+            return "".join(model.index(i, 0).data() for i in range(n))
+
+        score_table = ScoreTable(None)
+
+        data = [
+            ["D", 11.0, 15.3],
+            ["C", 5.0, -15.4],
+            ["b", 20.0, np.nan],
+            ["A", None, None],
+            ["E", "", 0.0]
+        ]
+        for data_row in data:
+            row = []
+            for x in data_row:
+                item = QStandardItem()
+                if x is not None:
+                    item.setData(x, Qt.DisplayRole)
+                row.append(item)
+            score_table.model.appendRow(row)
+
+        model = score_table.view.model()
+
+        model.sort(0, Qt.AscendingOrder)
+        self.assertEqual(order(), "AbCDE")
+
+        model.sort(0, Qt.DescendingOrder)
+        self.assertEqual(order(), "EDCbA")
+
+        model.sort(1, Qt.AscendingOrder)
+        self.assertEqual(order(3), "CDb")
+
+        model.sort(1, Qt.DescendingOrder)
+        self.assertEqual(order(3), "bDC")
+
+        model.sort(2, Qt.AscendingOrder)
+        self.assertEqual(order(3), "CED")
+
+        model.sort(2, Qt.DescendingOrder)
+        self.assertEqual(order(3), "DEC")
+
+
 if __name__ == "__main__":
     unittest.main()