Repository: biolab/orange3

Issue:
Feature Statistics widget: no statistics output. regression?
<!-- 
Thanks for taking the time to report a bug!
If you're raising an issue about an add-on (i.e., installed via Options > Add-ons), raise an issue in the relevant add-on's issue tracker instead. See: https://github.com/biolab?q=orange3
To fix the bug, we need to be able to reproduce it. Please answer the following questions to the best of your ability.
-->

**What's wrong?**
<!-- Be specific, clear, and concise. Include screenshots if relevant. -->
<!-- If you're getting an error message, copy it, and enclose it with three backticks (```). -->
It seems that there is no output statistics from the Feature Statistics widget.


**How can we reproduce the problem?** 
<!-- Upload a zip with the .ows file and data. -->
<!-- Describe the steps (open this widget, click there, then add this...) -->
Add widget Datasets.  Select Auto MPG
Add widget Feature Statistics and link Datasets -> Feature statistics input
Add widget Data Table and link Feature_Statistics -> (Statistics) -> to Data Table

Look at the Data Table, it is empty.

I expected to see a data table with feature statistics (mean, median, etc) similar to here https://orangedatamining.com/widget-catalog/data/images/feature_statistics_example2.png


**What's your environment?**
<!-- To find your Orange version, see "Help → About → Version" or `Orange.version.full_version` in code -->
- Operating system: Ubuntu
- Orange version: 3.30.1
- How you installed Orange: pip.



Discussion:
Feature Statistics outputs selected data. So you likely have not selected anything, hence the widget doesn't output the data.

@janezd @markotoplak Perhaps the Statistics output should output everything without selection?
Thanks for the reply @ajdapretnar 

I can confirm that selecting the rows in Feature statistics widget makes statistics output appear.  This behaviour was not obvious to me - hence the bug report.

I agree with your suggestion of outputting everything without selection.  I think that is more intuitive behaviour.
Outputting everything instead of a selection when non-selected may be problematic if additional computation is needed. Therefore I would not make it a default. Currently Orange has a single exception: a data table, and I'd keep it that way.
The question here is: should this particular output be selection dependent at all? For example, Scores coming from rank do not depend on the selection.
That's what I meant - it should not be selection dependent. It is exactly like Scores, it just output what is shown in the widget in a Table format.
> Outputting everything instead of a selection when non-selected may be problematic if additional computation is needed. Therefore I would not make it a default. Currently Orange has a single exception: a data table, and I'd keep it that way.

I thought all widgets do computation by default?  Changing input data to a model and the learner immediately runs again.  Users quickly learn to disconnect widgets or disable automatic signal propagation.  Is feature statistics different?
Another option to clarify usage that avoids computation is to document the widget outputs differently:

Outputs:
   * Reduced data
   * Statistics

 becomes

Outputs:
   * Reduced data
   * Statistics on selection


Resolving patch:
diff --git a/Orange/widgets/data/owfeaturestatistics.py b/Orange/widgets/data/owfeaturestatistics.py
--- a/Orange/widgets/data/owfeaturestatistics.py
+++ b/Orange/widgets/data/owfeaturestatistics.py
@@ -84,19 +84,19 @@ def format_time_diff(start, end, round_up_after=2):
 
     # Check which resolution is most appropriate
     if years >= round_up_after:
-        return '~%d years' % years
+        return f'~{years} years'
     elif months >= round_up_after:
-        return '~%d months' % months
+        return f'~{months} months'
     elif weeks >= round_up_after:
-        return '~%d weeks' % weeks
+        return f'~{weeks} weeks'
     elif days >= round_up_after:
-        return '~%d days' % days
+        return f'~{days} days'
     elif hours >= round_up_after:
-        return '~%d hours' % hours
+        return f'~{hours} hours'
     elif minutes >= round_up_after:
-        return '~%d minutes' % minutes
+        return f'~{minutes} minutes'
     else:
-        return '%d seconds' % seconds
+        return f'{seconds} seconds'
 
 
 class FeatureStatisticsTableModel(AbstractSortTableModel):
@@ -227,7 +227,7 @@ def _attr_indices(attrs):
 
     def __filter_attributes(self, attributes, matrix):
         """Filter out variables which shouldn't be visualized."""
-        attributes, matrix = np.asarray(attributes), matrix
+        attributes = np.asarray(attributes)
         mask = [idx for idx, attr in enumerate(attributes)
                 if not isinstance(attr, self.HIDDEN_VAR_TYPES)]
         return attributes[mask], matrix[:, mask]
@@ -576,7 +576,7 @@ def render_value(value):
                 if isinstance(attribute, TimeVariable):
                     return format_time_diff(self._min[row], self._max[row])
                 elif isinstance(attribute, DiscreteVariable):
-                    return "%.3g" % self._dispersion[row]
+                    return f"{self._dispersion[row]:.3g}"
                 else:
                     return render_value(self._dispersion[row])
             elif column == self.Columns.MIN:
@@ -586,10 +586,9 @@ def render_value(value):
                 if not isinstance(attribute, DiscreteVariable):
                     return render_value(self._max[row])
             elif column == self.Columns.MISSING:
-                return '%d (%d%%)' % (
-                    self._missing[row],
-                    100 * self._missing[row] / self.n_instances
-                )
+                missing = self._missing[row]
+                perc = int(round(100 * missing / self.n_instances))
+                return f'{missing} ({perc} %)'
             return None
 
         roles = {Qt.BackgroundRole: background,
@@ -776,7 +775,7 @@ def __init__(self):
         gui.auto_send(self.buttonsArea, self, "auto_commit")
 
     @staticmethod
-    def sizeHint():
+    def sizeHint():  # pylint: disable=arguments-differ
         return QSize(1050, 500)
 
     @Inputs.data
@@ -851,22 +850,23 @@ def on_select(self):
     def commit(self):
         if not self.selected_vars:
             self.Outputs.reduced_data.send(None)
+        else:
+            # Send a table with only selected columns to output
+            self.Outputs.reduced_data.send(self.data[:, self.selected_vars])
+
+        if not self.data:
             self.Outputs.statistics.send(None)
             return
 
-        # Send a table with only selected columns to output
-        variables = self.selected_vars
-        self.Outputs.reduced_data.send(self.data[:, variables])
-
         # Send the statistics of the selected variables to ouput
-        labels, data = self.model.get_statistics_matrix(variables, return_labels=True)
-        var_names = np.atleast_2d([var.name for var in variables]).T
+        labels, data = self.model.get_statistics_matrix(return_labels=True)
+        var_names = np.atleast_2d([var.name for var in self.model.variables]).T
         domain = Domain(
             attributes=[ContinuousVariable(name) for name in labels],
             metas=[StringVariable('Feature')]
         )
         statistics = Table(domain, data, metas=var_names)
-        statistics.name = '%s (Feature Statistics)' % self.data.name
+        statistics.name = '{self.data.name} (Feature Statistics)'
         self.Outputs.statistics.send(statistics)
 
     def send_report(self):


Test patch:
diff --git a/Orange/widgets/data/tests/test_owfeaturestatistics.py b/Orange/widgets/data/tests/test_owfeaturestatistics.py
--- a/Orange/widgets/data/tests/test_owfeaturestatistics.py
+++ b/Orange/widgets/data/tests/test_owfeaturestatistics.py
@@ -325,7 +325,7 @@ def setUp(self):
         self.send_signal(self.widget.Inputs.data, self.data)
         self.select_rows = partial(select_rows, widget=self.widget)
 
-    def test_changing_data_updates_ouput(self):
+    def test_changing_data_updates_output(self):
         # Test behaviour of widget when auto commit is OFF
         self.widget.auto_commit = False
 
@@ -334,33 +334,27 @@ def test_changing_data_updates_ouput(self):
         self.select_rows([0])
         # By default, nothing should be sent since auto commit is off
         self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNone(self.get_output(self.widget.Outputs.statistics))
         # When we commit, the data should be on the output
         self.widget.unconditional_commit()
         self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics))
 
         # Send some new data
         iris = Table('iris')
         self.send_signal(self.widget.Inputs.data, iris)
         # By default, there should be nothing on the output
         self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNone(self.get_output(self.widget.Outputs.statistics))
         # Nothing should change after commit, since we haven't selected any rows
         self.widget.unconditional_commit()
         self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNone(self.get_output(self.widget.Outputs.statistics))
 
         # Now let's switch back to the original data, where we selected row 0
         self.send_signal(self.widget.Inputs.data, self.data)
         # Again, since auto commit is off, nothing should be on the output
         self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNone(self.get_output(self.widget.Outputs.statistics))
         # Since the row selection is saved into context settings, the appropriate
         # thing should be sent to output
         self.widget.unconditional_commit()
         self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics))
 
     def test_changing_data_updates_output_with_autocommit(self):
         # Test behaviour of widget when auto commit is ON
@@ -371,20 +365,17 @@ def test_changing_data_updates_output_with_autocommit(self):
         self.select_rows([0])
         # Selecting rows should send data to output
         self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics))
 
         # Send some new data
         iris = Table('iris')
         self.send_signal(self.widget.Inputs.data, iris)
         # Don't select anything, so the outputs should be empty
         self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNone(self.get_output(self.widget.Outputs.statistics))
 
         # Now let's switch back to the original data, where we had selected row 0,
         # we expect that to be sent to output
         self.send_signal(self.widget.Inputs.data, self.data)
         self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics))
 
     def test_sends_single_attribute_table_to_output(self):
         # Check if selecting a single attribute row
@@ -453,12 +444,31 @@ def test_clearing_selection_sends_none_to_output(self):
         self.select_rows([0])
         self.widget.unconditional_commit()
         self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics))
 
         self.widget.table_view.clearSelection()
         self.widget.unconditional_commit()
         self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
-        self.assertIsNone(self.get_output(self.widget.Outputs.statistics))
+
+    def test_output_combinations(self):
+        # No selection -> reduced_data is not output, statistics is present
+        self.widget.unconditional_commit()
+        self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
+        self.assertEqual(len(self.get_output(self.widget.Outputs.statistics)),
+                         self.widget.model.rowCount())
+
+        # Has selection -> all outputs present
+        self.select_rows([0, 1])
+        self.widget.unconditional_commit()
+        outp = self.get_output(self.widget.Outputs.reduced_data)
+        self.assertEqual(len(outp), len(self.data))
+        self.assertEqual(len(outp.domain.variables), 2)
+        self.assertEqual(len(self.get_output(self.widget.Outputs.statistics)),
+                         self.widget.model.rowCount())
+
+        # No data -> no output
+        self.send_signal(self.widget.Inputs.data, None)
+        self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
+        self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data))
 
 
 class TestFeatureStatisticsUI(WidgetTest):