diff --git a/Orange/preprocess/discretize.py b/Orange/preprocess/discretize.py index 1502b97f0f4..ba84b1ec424 100644 --- a/Orange/preprocess/discretize.py +++ b/Orange/preprocess/discretize.py @@ -386,7 +386,7 @@ def _time_binnings(mn, mx, min_pts, max_pts): if not times: continue times = [time.struct_time(t + (0, 0, 0)) for t in times] - thresholds = [calendar.timegm(t) for t in times] + thresholds = np.array([calendar.timegm(t) for t in times]) labels = [time.strftime(fmt, t) for t in times] short_labels = _simplified_labels(labels) if place == 2 and step >= 7: diff --git a/Orange/preprocess/tests/test_discretize.py b/Orange/preprocess/tests/test_discretize.py index 2ad7ad1cbb6..e292d66242f 100644 --- a/Orange/preprocess/tests/test_discretize.py +++ b/Orange/preprocess/tests/test_discretize.py @@ -39,7 +39,8 @@ def tr(ss): def testbin(start, end): bins = _time_binnings(create(*start), create(*end), 3, 51) - return [(bin.width_label, tr(bin.short_labels), bin.thresholds) + return [(bin.width_label, tr(bin.short_labels), + list(bin.thresholds)) for bin in reversed(bins)] self.assertEqual( diff --git a/Orange/widgets/unsupervised/owsom.py b/Orange/widgets/unsupervised/owsom.py index 65cdda50f5d..f6d748fc7ac 100644 --- a/Orange/widgets/unsupervised/owsom.py +++ b/Orange/widgets/unsupervised/owsom.py @@ -1,4 +1,5 @@ from collections import defaultdict, namedtuple +from typing import Optional from xml.sax.saxutils import escape import numpy as np @@ -26,7 +27,7 @@ from Orange.widgets.utils.annotated_data import \ create_annotated_table, create_groups_table, ANNOTATED_DATA_SIGNAL_NAME from Orange.widgets.utils.colorpalettes import \ - BinnedContinuousPalette, LimitedDiscretePalette + BinnedContinuousPalette, LimitedDiscretePalette, DiscretePalette from Orange.widgets.visualize.utils import CanvasRectangle, CanvasText from Orange.widgets.visualize.utils.plotutils import wrap_legend_items @@ -210,6 +211,8 @@ class Warning(OWWidget.Warning): ignoring_disc_variables = Msg("SOM ignores categorical variables.") missing_colors = \ Msg("Some data instances have undefined value of '{}'.") + no_defined_colors = \ + Msg("'{}' has no defined values.") missing_values = \ Msg("{} data instance{} with undefined value(s) {} not shown.") single_attribute = Msg("Data contains a single numeric column.") @@ -228,7 +231,12 @@ def __init__(self): self.data = self.cont_x = None self.cells = self.member_data = None self.selection = None - self.colors = self.thresholds = self.bin_labels = None + + # self.colors holds a palette or None when we need to draw same-colored + # circles. This happens by user's choice or when the color attribute + # is numeric and has no defined values, so we can't construct bins + self.colors: Optional[DiscretePalette] = None + self.thresholds = self.bin_labels = None box = gui.vBox(self.controlArea, box="SOM") shape = gui.comboBox( @@ -536,7 +544,7 @@ def _redraw(self): self.elements = QGraphicsItemGroup() self.scene.addItem(self.elements) - if self.attr_color is None: + if self.colors is None: self._draw_same_color(sizes) elif self.pie_charts: self._draw_pie_charts(sizes) @@ -562,6 +570,10 @@ def _draw_same_color(self, sizes): self.elements.addToGroup(ellipse) def _get_color_column(self): + # if self.colors is None, we use _draw_same_color and don't call + # this function + assert self.colors is not None + color_column = \ self.data.get_column_view(self.attr_color)[0].astype(float, copy=False) @@ -571,10 +583,7 @@ def _get_color_column(self): int_col[np.isnan(color_column)] = len(self.colors) else: int_col = np.zeros(len(color_column), dtype=int) - # The following line is unnecessary because rows with missing - # numeric data are excluded. Uncomment it if you change SOM to - # tolerate missing values. - # int_col[np.isnan(color_column)] = len(self.colors) + int_col[np.isnan(color_column)] = len(self.colors) for i, thresh in enumerate(self.thresholds, start=1): int_col[color_column >= thresh] = i return int_col @@ -584,6 +593,7 @@ def _tooltip(self, colors, distribution): values = self.attr_color.values else: values = self._bin_names() + values = list(values) + ["(N/A)"] tot = np.sum(distribution) nbhp = "\N{NON-BREAKING HYPHEN}" return '' + "".join(f""" @@ -600,6 +610,8 @@ def _tooltip(self, colors, distribution): + "
" def _draw_pie_charts(self, sizes): + assert self.colors is not None # if it were, we'd call _draw_same_color + fx, fy = self._grid_factors color_column = self._get_color_column() colors = self.colors.qcolors_w_nan @@ -619,6 +631,8 @@ def _draw_pie_charts(self, sizes): pie.setPos(x + (y % 2) * fx, y * fy) def _draw_colored_circles(self, sizes): + assert self.colors is not None # if it were, we'd call _draw_same_color + fx, fy = self._grid_factors color_column = self._get_color_column() qcolors = self.colors.qcolors_w_nan @@ -820,28 +834,50 @@ def update_output(self): self.Outputs.annotated_data.send(annotated) def set_color_bins(self): + self.Warning.no_defined_colors.clear() + if self.attr_color is None: self.thresholds = self.bin_labels = self.colors = None - elif self.attr_color.is_discrete: + return + + if self.attr_color.is_discrete: self.thresholds = self.bin_labels = None self.colors = self.attr_color.palette + return + + col = self.data.get_column_view(self.attr_color)[0].astype(float) + col = col[np.isfinite(col)] + if not col.size: + self.Warning.no_defined_colors(self.attr_color) + self.thresholds = self.bin_labels = self.colors = None + return + + if self.attr_color.is_time: + binning = time_binnings(col, min_bins=4)[-1] else: - col = self.data.get_column_view(self.attr_color)[0].astype(float) - if self.attr_color.is_time: - binning = time_binnings(col, min_bins=4)[-1] - else: - binning = decimal_binnings(col, min_bins=4)[-1] - self.thresholds = binning.thresholds[1:-1] - self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1]) - palette = BinnedContinuousPalette.from_palette( - self.attr_color.palette, binning.thresholds) - self.colors = palette + binning = decimal_binnings(col, min_bins=4)[-1] + self.thresholds = binning.thresholds[1:-1] + self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1]) + if not self.bin_labels[0] and binning.labels: + # Nan's are already filtered out, but it doesn't hurt much + # to use nanmax/nanmin + if np.nanmin(col) == np.nanmax(col): + # Handle a degenerate case with a single value + # Use the second threshold (because value must be smaller), + # but the first threshold as label (because that's the + # actual value in the data. + self.thresholds = binning.thresholds[1:] + self.bin_labels = (binning.labels[:1], + binning.short_labels[:1]) + palette = BinnedContinuousPalette.from_palette( + self.attr_color.palette, binning.thresholds) + self.colors = palette def create_legend(self): if self.legend is not None: self.scene.removeItem(self.legend) self.legend = None - if self.attr_color is None: + if self.colors is None: return if self.attr_color.is_discrete: @@ -870,7 +906,9 @@ def create_legend(self): self.set_legend_pos() def _bin_names(self): - labels, short_labels = self.bin_labels + labels, short_labels = self.bin_labels or ([], []) + if len(labels) <= 1: + return labels return \ [f"< {labels[0]}"] \ + [f"{x} - {y}" for x, y in zip(labels, short_labels[1:])] \ @@ -885,7 +923,7 @@ def set_legend_pos(self): def send_report(self): self.report_plot() - if self.attr_color: + if self.colors: self.report_caption( f"Self-organizing map colored by '{self.attr_color.name}'") diff --git a/Orange/widgets/unsupervised/tests/test_owsom.py b/Orange/widgets/unsupervised/tests/test_owsom.py index 7d6630b389b..f9ebe851406 100644 --- a/Orange/widgets/unsupervised/tests/test_owsom.py +++ b/Orange/widgets/unsupervised/tests/test_owsom.py @@ -8,6 +8,7 @@ from Orange.data import Table, Domain from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import simulate from Orange.widgets.utils.annotated_data import ANNOTATED_DATA_FEATURE_NAME from Orange.widgets.unsupervised.owsom import OWSOM, SomView, SOM @@ -220,6 +221,42 @@ def test_attr_color_change(self): self.assertIsNotNone(widget.thresholds) widget._redraw.assert_called() + def test_colored_circles_with_constant(self): + domain = self.iris.domain + self.widget.pie_charts = False + + with self.iris.unlocked(): + self.iris.X[:, 0] = 1 + self.send_signal(self.widget.Inputs.data, self.iris) + attr0 = domain.attributes[0] + + combo = self.widget.controls.attr_color + simulate.combobox_activate_index(combo, combo.model().indexOf(attr0)) + self.assertIsNotNone(self.widget.colors) + self.assertFalse(self.widget.Warning.no_defined_colors.is_shown()) + + dom1 = Domain(domain.attributes[1:], domain.class_var, + domain.attributes[:1]) + iris = self.iris.transform(dom1).copy() + with iris.unlocked(iris.metas): + iris.metas[::2, 0] = np.nan + self.send_signal(self.widget.Inputs.data, iris) + simulate.combobox_activate_index(combo, combo.model().indexOf(attr0)) + self.assertIsNotNone(self.widget.colors) + self.assertFalse(self.widget.Warning.no_defined_colors.is_shown()) + + iris = self.iris.transform(dom1).copy() + with iris.unlocked(iris.metas): + iris.metas[:, 0] = np.nan + self.send_signal(self.widget.Inputs.data, iris) + simulate.combobox_activate_index(combo, combo.model().indexOf(attr0)) + self.assertIsNone(self.widget.colors) + self.assertTrue(self.widget.Warning.no_defined_colors.is_shown()) + + simulate.combobox_activate_index(combo, 0) + self.assertIsNone(self.widget.colors) + self.assertFalse(self.widget.Warning.no_defined_colors.is_shown()) + @_patch_recompute_som def test_cell_sizes(self): widget = self.widget