Repository: biolab/orange3

Issue:
Why does Orange allow plotting discrete variables in scatter plots?!
I'm just preparing lectures and I'm thinking about how to persuade students against using scatter plots for showing discrete variables. Student often (ab)use the scatter plot instead of using more appropriate visualizations. But then I thought --- why does Orange allow it in the first place?!?!

A semi-useful case is to put the gender on y-axis and age on x-axis and see whether the age of women in our data is lower or higher or more spread out than the men's. However, what we actually observe in this case are distributions, and there's another, better widget for that (Distribution, Box plot). (And which does not require explaining away the jittering of genders.)

Plotting two discrete variables in a scatter plot is probably always a bad idea. If we plot, say, gender vs. four different types of chest pain, we project all data into just 2 x 4 = 8 points and then use jittering to spread them around these eight points. What kind of information can we get from this visualization? We can count the points around each of the few points (8, in above case). Or use the famous "overlap by size" feature to represent the counts by sizes --- which are known to be a bad way to visualize and compare magnitudes. This information is presented much better in Box plots, Distributions and Mosaic. This also goes for observing colors of jittered points; mosaic does it better.

Does anybody have a good argument for allowing discrete variables for scatter plots axes? Something that is not done much better with Distributions, Mosaic and Sieve? Aren't we promoting a bad practice here?


Discussion:

Resolving patch:
diff --git a/Orange/widgets/visualize/owscatterplot.py b/Orange/widgets/visualize/owscatterplot.py
--- a/Orange/widgets/visualize/owscatterplot.py
+++ b/Orange/widgets/visualize/owscatterplot.py
@@ -11,16 +11,15 @@
 
 import pyqtgraph as pg
 
-from Orange.data import Table, Domain, DiscreteVariable, Variable
+from Orange.data import Table, Domain, DiscreteVariable, Variable, \
+    ContinuousVariable
 from Orange.data.sql.table import SqlTable, AUTO_DL_LIMIT
 from Orange.preprocess.score import ReliefF, RReliefF
 
-from Orange.widgets import gui, report
+from Orange.widgets import gui
 from Orange.widgets.io import MatplotlibFormat, MatplotlibPDFFormat
 from Orange.widgets.settings import (
-    Setting, ContextSetting, SettingProvider
-)
-from Orange.widgets.utils import get_variable_values_sorted
+    Setting, ContextSetting, SettingProvider, IncompatibleContext)
 from Orange.widgets.utils.itemmodels import DomainModel
 from Orange.widgets.utils.widgetpreview import WidgetPreview
 from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase
@@ -97,7 +96,6 @@ def score_heuristic(self):
 
 class OWScatterPlotGraph(OWScatterPlotBase):
     show_reg_line = Setting(False)
-    jitter_continuous = Setting(False)
 
     def __init__(self, scatter_widget, parent):
         super().__init__(scatter_widget, parent)
@@ -107,52 +105,18 @@ def clear(self):
         super().clear()
         self.reg_line_item = None
 
-    def set_axis_labels(self, axis, labels):
-        axis = self.plot_widget.getAxis(axis)
-        if labels:
-            axis.setTicks([list(enumerate(labels))])
-        else:
-            axis.setTicks(None)
-
     def set_axis_title(self, axis, title):
         self.plot_widget.setLabel(axis=axis, text=title)
 
     def update_coordinates(self):
         super().update_coordinates()
         self.update_regression_line()
-        self.update_tooltip()
-
-    def _get_jittering_tooltip(self):
-        def is_discrete(attr):
-            return attr and attr.is_discrete
-
-        if self.jitter_continuous or is_discrete(self.master.attr_x) or \
-                is_discrete(self.master.attr_y):
-            return super()._get_jittering_tooltip()
-        return ""
-
-    def jitter_coordinates(self, x, y):
-        def get_span(attr):
-            if attr.is_discrete:
-                # Assuming the maximal jitter size is 10, a span of 4 will
-                # jitter by 4 * 10 / 100 = 0.4, so there will be no overlap
-                return 4
-            elif self.jitter_continuous:
-                return None  # Let _jitter_data determine the span
-            else:
-                return 0  # No jittering
-        span_x = get_span(self.master.attr_x)
-        span_y = get_span(self.master.attr_y)
-        if self.jitter_size == 0 or (span_x == 0 and span_y == 0):
-            return x, y
-        return self._jitter_data(x, y, span_x, span_y)
 
     def update_regression_line(self):
         if self.reg_line_item is not None:
             self.plot_widget.removeItem(self.reg_line_item)
             self.reg_line_item = None
-        if not (self.show_reg_line
-                and self.master.can_draw_regresssion_line()):
+        if not self.show_reg_line:
             return
         x, y = self.master.get_coordinates_data()
         if x is None:
@@ -194,7 +158,7 @@ class Inputs(OWDataProjectionWidget.Inputs):
     class Outputs(OWDataProjectionWidget.Outputs):
         features = Output("Features", AttributeList, dynamic=False)
 
-    settings_version = 3
+    settings_version = 4
     auto_sample = Setting(True)
     attr_x = ContextSetting(None)
     attr_y = ContextSetting(None)
@@ -208,6 +172,7 @@ class Warning(OWDataProjectionWidget.Warning):
         missing_coords = Msg(
             "Plot cannot be displayed because '{}' or '{}' "
             "is missing for all data points")
+        no_continuous_vars = Msg("Data has no continuous variables")
 
     class Information(OWDataProjectionWidget.Information):
         sampled_sql = Msg("Large SQL table; showing a sample.")
@@ -231,7 +196,6 @@ def _add_controls(self):
         self._add_controls_axis()
         self._add_controls_sampling()
         super()._add_controls()
-        self.gui.add_widget(self.gui.JitterNumericValues, self._effects_box)
         self.gui.add_widgets(
             [self.gui.ShowGridLines,
              self.gui.ToolTipShowsAll,
@@ -245,7 +209,7 @@ def _add_controls_axis(self):
         )
         box = gui.vBox(self.controlArea, True)
         dmod = DomainModel
-        self.xy_model = DomainModel(dmod.MIXED, valid_types=dmod.PRIMITIVE)
+        self.xy_model = DomainModel(dmod.MIXED, valid_types=ContinuousVariable)
         self.cb_attr_x = gui.comboBox(
             box, self, "attr_x", label="Axis x:", callback=self.attr_changed,
             model=self.xy_model, **common_options)
@@ -324,6 +288,11 @@ def check_data(self):
                 if self.auto_sample:
                     self.__timer.start()
 
+        if self.data is not None:
+            if not self.data.domain.has_continuous_attributes(True, True):
+                self.Warning.no_continuous_vars()
+                self.data = None
+
         if self.data is not None and (len(self.data) == 0 or
                                       len(self.data.domain) == 0):
             self.data = None
@@ -359,12 +328,6 @@ def _point_tooltip(self, point_id, skip_attrs=()):
                 text = "<b>{}</b><br/><br/>{}".format(text, others)
         return text
 
-    def can_draw_regresssion_line(self):
-        return self.data is not None and\
-               self.data.domain is not None and \
-               self.attr_x.is_continuous and \
-               self.attr_y.is_continuous
-
     def add_data(self, time=0.4):
         if self.data and len(self.data) > 2000:
             self.__timer.stop()
@@ -413,7 +376,6 @@ def handleNewSignals(self):
         else:
             super().handleNewSignals()
         self._vizrank_color_change()
-        self.cb_reg_line.setEnabled(self.can_draw_regresssion_line())
 
     @Inputs.features
     def set_shown_attributes(self, attributes):
@@ -428,19 +390,13 @@ def set_attr(self, attr_x, attr_y):
             self.attr_changed()
 
     def attr_changed(self):
-        self.cb_reg_line.setEnabled(self.can_draw_regresssion_line())
         self.setup_plot()
         self.commit()
 
     def setup_plot(self):
         super().setup_plot()
-        for axis, var in (("bottom", self.attr_x), ("left", self.attr_y)):
-            self.graph.set_axis_title(axis, var)
-            if var and var.is_discrete:
-                self.graph.set_axis_labels(axis,
-                                           get_variable_values_sorted(var))
-            else:
-                self.graph.set_axis_labels(axis, None)
+        self.graph.set_axis_title("bottom", self.attr_x)
+        self.graph.set_axis_title("left", self.attr_y)
 
     def colors_changed(self):
         super().colors_changed()
@@ -459,17 +415,6 @@ def get_widget_name_extension(self):
             return "{} vs {}".format(self.attr_x.name, self.attr_y.name)
         return None
 
-    def _get_send_report_caption(self):
-        return report.render_items_vert((
-            ("Color", self._get_caption_var_name(self.attr_color)),
-            ("Label", self._get_caption_var_name(self.attr_label)),
-            ("Shape", self._get_caption_var_name(self.attr_shape)),
-            ("Size", self._get_caption_var_name(self.attr_size)),
-            ("Jittering", (self.attr_x.is_discrete or
-                           self.attr_y.is_discrete or
-                           self.graph.jitter_continuous) and
-             self.graph.jitter_size)))
-
     @classmethod
     def migrate_settings(cls, settings, version):
         if version < 2 and "selection" in settings and settings["selection"]:
@@ -482,12 +427,15 @@ def migrate_settings(cls, settings, version):
 
     @classmethod
     def migrate_context(cls, context, version):
+        values = context.values
         if version < 3:
-            values = context.values
             values["attr_color"] = values["graph"]["attr_color"]
             values["attr_size"] = values["graph"]["attr_size"]
             values["attr_shape"] = values["graph"]["attr_shape"]
             values["attr_label"] = values["graph"]["attr_label"]
+        if version < 4:
+            if values["attr_x"][1] % 100 == 1 or values["attr_y"][1] % 100 == 1:
+                raise IncompatibleContext()
 
 
 if __name__ == "__main__":  # pragma: no cover


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py
--- a/Orange/widgets/visualize/tests/test_owscatterplot.py
+++ b/Orange/widgets/visualize/tests/test_owscatterplot.py
@@ -134,18 +134,6 @@ def test_data_column_infs(self):
         attr_x = self.widget.controls.attr_x
         simulate.combobox_activate_item(attr_x, "b")
 
-    def test_regression_line(self):
-        """It is possible to draw the line only for pair of continuous attrs"""
-        self.send_signal(self.widget.Inputs.data, self.data)
-        self.assertTrue(self.widget.cb_reg_line.isEnabled())
-        self.assertIsNone(self.widget.graph.reg_line_item)
-        self.widget.cb_reg_line.setChecked(True)
-        self.assertIsNotNone(self.widget.graph.reg_line_item)
-        self.widget.cb_attr_y.activated.emit(4)
-        self.widget.cb_attr_y.setCurrentIndex(4)
-        self.assertFalse(self.widget.cb_reg_line.isEnabled())
-        self.assertIsNone(self.widget.graph.reg_line_item)
-
     def test_points_combo_boxes(self):
         """Check Point box combo models and values"""
         self.send_signal(self.widget.Inputs.data, self.data)
@@ -266,8 +254,8 @@ def test_points_selection(self):
         self.assertEqual(len(selected_data), 50)
 
         # Changing the dataset should clear selection
-        titanic = Table("titanic")
-        self.send_signal(self.widget.Inputs.data, titanic)
+        heart = Table("heart_disease")
+        self.send_signal(self.widget.Inputs.data, heart)
         selected_data = self.get_output(self.widget.Outputs.selected_data)
         self.assertIsNone(selected_data)
 
@@ -291,7 +279,6 @@ def test_set_strings_settings(self):
         """
         Test if settings can be loaded as strings and successfully put
         in new owplotgui combos.
-        GH-2240
         """
         self.send_signal(self.widget.Inputs.data, self.data)
         settings = self.widget.settingsHandler.pack_data(self.widget)
@@ -310,7 +297,6 @@ def test_set_strings_settings(self):
     def test_features_and_no_data(self):
         """
         Prevent crashing when features are sent but no data.
-        GH-2384
         """
         domain = Table("iris").domain
         self.send_signal(self.widget.Inputs.features,
@@ -391,7 +377,7 @@ def assert_vizrank_enabled(data, is_enabled):
 
     def test_vizrank_nonprimitives(self):
         """VizRank does not try to include non primitive attributes"""
-        data = Table("zoo")
+        data = Table("brown-selected")
         self.send_signal(self.widget.Inputs.data, data)
         with patch("Orange.widgets.visualize.owscatterplot.ReliefF",
                    new=lambda *_1, **_2: lambda data: np.arange(len(data))):
@@ -415,56 +401,56 @@ def test_auto_send_selection(self):
         self.assertIsInstance(output, Table)
 
     def test_color_is_optional(self):
-        zoo = Table("zoo")
-        backbone, breathes, airborne, type = \
-            [zoo.domain[x] for x in ["backbone", "breathes", "airborne", "type"]]
-        default_x, default_y, default_color = \
-            zoo.domain[0], zoo.domain[1], zoo.domain.class_var
+        heart = Table("heart_disease")
+        age, rest_sbp, max_hr, cholesterol, gender, narrowing = \
+            [heart.domain[x]
+             for x in ["age", "rest SBP", "max HR", "cholesterol", "gender",
+                       "diameter narrowing"]]
         attr_x = self.widget.controls.attr_x
         attr_y = self.widget.controls.attr_y
         attr_color = self.widget.controls.attr_color
 
         # Send dataset, ensure defaults are what we expect them to be
-        self.send_signal(self.widget.Inputs.data, zoo)
-        self.assertEqual(attr_x.currentText(), default_x.name)
-        self.assertEqual(attr_y.currentText(), default_y.name)
-        self.assertEqual(attr_color.currentText(), default_color.name)
+        self.send_signal(self.widget.Inputs.data, heart)
+        self.assertEqual(attr_x.currentText(), age.name)
+        self.assertEqual(attr_y.currentText(), rest_sbp.name)
+        self.assertEqual(attr_color.currentText(), narrowing.name)
         # Select different values
-        simulate.combobox_activate_item(attr_x, backbone.name)
-        simulate.combobox_activate_item(attr_y, breathes.name)
-        simulate.combobox_activate_item(attr_color, airborne.name)
+        simulate.combobox_activate_item(attr_x, max_hr.name)
+        simulate.combobox_activate_item(attr_y, cholesterol.name)
+        simulate.combobox_activate_item(attr_color, gender.name)
 
         # Send compatible dataset, values should not change
-        zoo2 = zoo[:, (backbone, breathes, airborne, type)]
-        self.send_signal(self.widget.Inputs.data, zoo2)
-        self.assertEqual(attr_x.currentText(), backbone.name)
-        self.assertEqual(attr_y.currentText(), breathes.name)
-        self.assertEqual(attr_color.currentText(), airborne.name)
+        heart2 = heart[:, (cholesterol, gender, max_hr, narrowing)]
+        self.send_signal(self.widget.Inputs.data, heart2)
+        simulate.combobox_activate_item(attr_x, max_hr.name)
+        simulate.combobox_activate_item(attr_y, cholesterol.name)
+        simulate.combobox_activate_item(attr_color, gender.name)
 
         # Send dataset without color variable
         # x and y should remain, color reset to default
-        zoo3 = zoo[:, (backbone, breathes, type)]
-        self.send_signal(self.widget.Inputs.data, zoo3)
-        self.assertEqual(attr_x.currentText(), backbone.name)
-        self.assertEqual(attr_y.currentText(), breathes.name)
-        self.assertEqual(attr_color.currentText(), default_color.name)
+        heart3 = heart[:, (age, max_hr, cholesterol, narrowing)]
+        self.send_signal(self.widget.Inputs.data, heart3)
+        simulate.combobox_activate_item(attr_x, max_hr.name)
+        simulate.combobox_activate_item(attr_y, cholesterol.name)
+        self.assertEqual(attr_color.currentText(), narrowing.name)
 
         # Send dataset without x
-        # y and color should be the same as with zoo
-        zoo4 = zoo[:, (default_x, default_y, breathes, airborne, type)]
-        self.send_signal(self.widget.Inputs.data, zoo4)
-        self.assertEqual(attr_x.currentText(), default_x.name)
-        self.assertEqual(attr_y.currentText(), default_y.name)
-        self.assertEqual(attr_color.currentText(), default_color.name)
-
-        # Send dataset compatible with zoo2 and zoo3
-        # Color should reset to one in zoo3, as it was used more
+        # y and color should be the same as with heart
+        heart4 = heart[:, (age, rest_sbp, cholesterol, narrowing)]
+        self.send_signal(self.widget.Inputs.data, heart4)
+        self.assertEqual(attr_x.currentText(), age.name)
+        self.assertEqual(attr_y.currentText(), rest_sbp.name)
+        self.assertEqual(attr_color.currentText(), narrowing.name)
+
+        # Send dataset compatible with heart2 and heart3
+        # Color should reset to one in heart3, as it was used more
         # recently
-        zoo5 = zoo[:, (default_x, backbone, breathes, airborne, type)]
-        self.send_signal(self.widget.Inputs.data, zoo5)
-        self.assertEqual(attr_x.currentText(), backbone.name)
-        self.assertEqual(attr_y.currentText(), breathes.name)
-        self.assertEqual(attr_color.currentText(), type.name)
+        heart5 = heart[:, (age, max_hr, cholesterol, gender, narrowing)]
+        self.send_signal(self.widget.Inputs.data, heart5)
+        simulate.combobox_activate_item(attr_x, max_hr.name)
+        simulate.combobox_activate_item(attr_y, cholesterol.name)
+        self.assertEqual(attr_color.currentText(), narrowing.name)
 
     def test_handle_metas(self):
         """
@@ -518,10 +504,10 @@ def test_tooltip(self):
         self.send_signal(self.widget.Inputs.data, data)
         widget = self.widget
         graph = widget.graph
-        scatterplot_item = graph.scatterplot_item
 
-        widget.controls.attr_x = data.domain["chest pain"]
-        widget.controls.attr_y = data.domain["cholesterol"]
+        widget.attr_x = data.domain["age"]
+        widget.attr_y = data.domain["max HR"]
+        scatterplot_item = graph.scatterplot_item
         all_points = scatterplot_item.points()
 
         event = MagicMock()
@@ -536,8 +522,8 @@ def test_tooltip(self):
                 self.assertTrue(graph.help_event(event))
                 (_, text), _ = show_text.call_args
                 self.assertIn("age = {}".format(data[42, "age"]), text)
-                self.assertIn("gender = {}".format(data[42, "gender"]), text)
-                self.assertNotIn("max HR = {}".format(data[42, "max HR"]), text)
+                self.assertIn("max HR = {}".format(data[42, "max HR"]), text)
+                self.assertNotIn("gender = {}".format(data[42, "gender"]), text)
                 self.assertNotIn("others", text)
 
                 # Show all attributes
@@ -582,7 +568,7 @@ def prepare_data():
 
         def assert_equal(data, max):
             self.send_signal(self.widget.Inputs.data, data)
-            pen_data, brush_data = self.widget.graph.get_colors()
+            pen_data, _ = self.widget.graph.get_colors()
             self.assertEqual(max, len(np.unique([id(p) for p in pen_data])), )
 
         assert_equal(prepare_data(), MAX_CATEGORIES)
@@ -591,6 +577,18 @@ def assert_equal(data, max):
         data.Y[42] = np.nan
         assert_equal(data, MAX_CATEGORIES + 1)
 
+    def test_change_data(self):
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.send_signal(self.widget.Inputs.data, Table("titanic"))
+        self.assertTrue(self.widget.Warning.no_continuous_vars.is_shown())
+        self.assertIsNone(self.widget.data)
+        self.assertIsNone(self.get_output(self.widget.Outputs.annotated_data))
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.assertFalse(self.widget.Warning.no_continuous_vars.is_shown())
+        self.assertIs(self.widget.data, self.data)
+        self.assertIsNotNone(
+            self.get_output(self.widget.Outputs.annotated_data))
+
 
 if __name__ == "__main__":
     import unittest