From 84a486d9cbc87079ca39be67e249dafc8a024555 Mon Sep 17 00:00:00 2001 From: janezd Date: Sun, 20 Aug 2023 16:27:45 +0200 Subject: [PATCH 1/3] Implement Orange.widget.utils.signals.lazy_table_transform --- Orange/widgets/utils/signals.py | 14 ++++++++++- Orange/widgets/utils/tests/test_signals.py | 27 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 Orange/widgets/utils/tests/test_signals.py diff --git a/Orange/widgets/utils/signals.py b/Orange/widgets/utils/signals.py index 014bc81b616..2550c79cc60 100644 --- a/Orange/widgets/utils/signals.py +++ b/Orange/widgets/utils/signals.py @@ -1,8 +1,12 @@ +from typing import Optional + from orangewidget.utils.signals import ( Input, Output, Single, Multiple, Default, NonDefault, Explicit, Dynamic, - InputSignal, OutputSignal, WidgetSignalsMixin + InputSignal, OutputSignal, WidgetSignalsMixin, LazyValue ) +from Orange.data import Table, Domain + __all__ = [ "Input", "Output", "InputSignal", "OutputSignal", "Single", "Multiple", "Default", "NonDefault", "Explicit", "Dynamic", @@ -12,3 +16,11 @@ class AttributeList(list): """Signal type for lists of attributes (variables)""" + + +def lazy_table_transform(domain: Domain, + data: Optional[Table]) -> LazyValue[Table]: + if data is None: + return None + return LazyValue[Table](lambda: data.transform(domain), + domain=domain, length=len(data)) diff --git a/Orange/widgets/utils/tests/test_signals.py b/Orange/widgets/utils/tests/test_signals.py new file mode 100644 index 00000000000..5bf6e43a577 --- /dev/null +++ b/Orange/widgets/utils/tests/test_signals.py @@ -0,0 +1,27 @@ +import unittest +from unittest.mock import Mock + +from Orange.widgets.utils.signals import lazy_table_transform + + +class TestSignals(unittest.TestCase): + def test_lazy_table_transform(self): + data = Mock() + data.__len__ = lambda _: 42 + data.transform = Mock() + + domain = Mock() + + lazy_trans = lazy_table_transform(domain, data) + + data.transform.assert_not_called() + self.assertEqual(lazy_trans.length, 42) + self.assertIs(lazy_trans.domain, domain) + self.assertFalse(lazy_trans.is_cached) + + self.assertIs(lazy_trans.get_value(), data.transform.return_value) + data.transform.assert_called_once() + + +if __name__ == '__main__': + unittest.main() From 9c2c45aed5507d38f727b19a73fb9a7debffab76 Mon Sep 17 00:00:00 2001 From: janezd Date: Sun, 20 Aug 2023 22:36:28 +0200 Subject: [PATCH 2/3] SOM: Add columns with cell and error --- Orange/projection/_som.pyx | 10 +- Orange/projection/som.py | 35 +++ Orange/widgets/unsupervised/owsom.py | 258 ++++++++++++++---- .../widgets/unsupervised/tests/test_owsom.py | 85 +++++- 4 files changed, 333 insertions(+), 55 deletions(-) diff --git a/Orange/projection/_som.pyx b/Orange/projection/_som.pyx index 84f6dc313aa..6410e20467f 100644 --- a/Orange/projection/_som.pyx +++ b/Orange/projection/_som.pyx @@ -22,6 +22,8 @@ def get_winners(np.float64_t[:, :, :] weights, np.float64_t[:, :] X, int hex): np.float64_t[:] row np.ndarray[np.int16_t, ndim=2] winners = \ np.empty((X.shape[0], 2), dtype=np.int16) + np.ndarray[np.float64_t, ndim=1] distances = \ + np.empty((X.shape[0]), dtype=np.float64) int nrows = X.shape[0] with nogil: @@ -40,8 +42,9 @@ def get_winners(np.float64_t[:, :, :] weights, np.float64_t[:, :] X, int hex): min_diff = diff winners[rowi, 0] = win_x winners[rowi, 1] = win_y + distances[rowi] = min_diff - return winners + return winners, distances def update(np.float64_t[:, :, :] weights, @@ -127,6 +130,8 @@ def get_winners_sparse(np.float64_t[:, :, :] weights, np.float64_t[:] row, np.ndarray[np.int16_t, ndim=2] winners = \ np.empty((X.shape[0], 2), dtype=np.int16) + np.ndarray[np.float64_t, ndim=1] distances = \ + np.empty((X.shape[0]), dtype=np.float64) int nrows = X.shape[0] with nogil: @@ -149,7 +154,8 @@ def get_winners_sparse(np.float64_t[:, :, :] weights, winners[rowi, 0] = win_x winners[rowi, 1] = win_y - return winners + distances[rowi] = min_diff + return winners, distances def update_sparse(np.ndarray[np.float64_t, ndim=3] weights, diff --git a/Orange/projection/som.py b/Orange/projection/som.py index c77865be689..a49113c6c91 100644 --- a/Orange/projection/som.py +++ b/Orange/projection/som.py @@ -1,3 +1,5 @@ +from typing import Union, Optional + import numpy as np import scipy.sparse as sp @@ -14,6 +16,39 @@ def __init__(self, dim_x, dim_y, self.pca_init = pca_init self.random_seed = random_seed + @staticmethod + def prepare_data(x: Union[np.ndarray, sp.spmatrix], + offsets: Optional[np.ndarray] = None, + scales: Optional[np.ndarray] = None) \ + -> (Union[np.ndarray, sp.spmatrix], + np.ndarray, + Union[np.ndarray, None], + Union[np.ndarray, None]): + if sp.issparse(x) and offsets is not None: + # This is used in compute_value, by any widget, hence there is no + # way to prevent it or report an error. We go dense... + x = x.todense() + if sp.issparse(x): + cont_x = x.tocsr() + mask = np.ones(cont_x.shape[0], bool) + else: + mask = np.all(np.isfinite(x), axis=1) + useful = np.sum(mask) + if useful == 0: + return x, mask, offsets, scales + if useful == len(mask): + cont_x = x.copy() + else: + cont_x = x[mask] + if offsets is None: + offsets = np.min(cont_x, axis=0) + cont_x -= offsets[None, :] + if scales is None: + scales = np.max(cont_x, axis=0) + scales[scales == 0] = 1 + cont_x /= scales[None, :] + return cont_x, mask, offsets, scales + def init_weights_random(self, x): random = (np.random if self.random_seed is None else np.random.RandomState(self.random_seed)) diff --git a/Orange/widgets/unsupervised/owsom.py b/Orange/widgets/unsupervised/owsom.py index 391aabdb7e1..63f9dc3510d 100644 --- a/Orange/widgets/unsupervised/owsom.py +++ b/Orange/widgets/unsupervised/owsom.py @@ -1,10 +1,9 @@ from collections import defaultdict, namedtuple from contextlib import contextmanager -from typing import Optional +from typing import Optional, Union from xml.sax.saxutils import escape import numpy as np -import scipy.sparse as sp from AnyQt.QtCore import Qt, QRectF, pyqtSignal as Signal, QObject, QThread, \ pyqtSlot as Slot @@ -15,8 +14,10 @@ QGraphicsItem, QGraphicsRectItem, QGraphicsItemGroup, QSizePolicy, \ QGraphicsPathItem -from Orange.data import Table, Domain -from Orange.data.util import array_equal +from Orange.widgets.utils.signals import lazy_table_transform + +from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable +from Orange.data.util import array_equal, SharedComputeValue, get_unique_names from Orange.preprocess import decimal_binnings, time_binnings from Orange.projection.som import SOM @@ -28,7 +29,8 @@ from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.annotated_data import \ - create_annotated_table, create_groups_table, ANNOTATED_DATA_SIGNAL_NAME + add_columns, group_values, \ + ANNOTATED_DATA_SIGNAL_NAME, ANNOTATED_DATA_FEATURE_NAME from Orange.widgets.utils.colorpalettes import \ BinnedContinuousPalette, LimitedDiscretePalette, DiscretePalette from Orange.widgets.visualize.utils import CanvasRectangle, CanvasText @@ -184,6 +186,115 @@ def disconnected_spin(spin): N_ITERATIONS = 200 +class SomSharedValueCompute: + def __init__(self, domain: Domain, model: SOM, + offsets: Union[np.ndarray, None], + scales: Union[np.ndarray, None]): + # offsets and scales are made immutable so that they are hashable + def immutable(a): + if a is not None: + a = a.copy() + a.flags.writeable = False + return a + + self.domain = domain + self.model = model + self.offsets = immutable(offsets) + self.scales = immutable(scales) + self.__hash = None + + def __getstate__(self): + state = self.__dict__.copy() + state["__hash"] = None + return state + + def __call__(self, data): + x = data.transform(self.domain).X + cont_x, mask, _, _ = SOM.prepare_data(x, self.offsets, self.scales) + winners = np.full((len(data), 2), np.nan) + distances = np.full(len(data), np.nan) + winners[mask], distances[mask] = self.model.winners(cont_x) + winners += 1 + return winners, distances + + # `offsets` and `scales` are ndarray's (and `model` contains ndarray's) + # Unless we __eq__ by identity, we can't properly define hash. + def __eq__(self, other): + return type(self) is type(other) and \ + self.domain == other.domain \ + and self.model == other.model \ + and np.array_equal(self.offsets, other.offsets) \ + and np.array_equal(self.scales, other.scales) + + def __hash__(self): + if self.__hash is None: + self.__hash = hash(( + self.domain, self.model, + tuple(self.offsets), tuple(self.scales))) + return self.__hash + + +class SomCellCompute(SharedComputeValue): + def __init__(self, compute_shared, dim_x, hexagonal): + super().__init__(compute_shared) + self.dim_x = dim_x + self.hexagonal = hexagonal + + def __eq__(self, other): + return super().__eq__(other) \ + and self.dim_x == other.dim_x \ + and self.hexagonal == other.hexagonal + + def __hash__(self): + return hash((super().__hash__(), self.dim_x, self.hexagonal)) + + def compute(self, _, shared_data): + coords = shared_data[0] + # coords are 1-based, subtract 1 + col = (coords[:, 0] - 1) + (coords[:, 1] - 1) * self.dim_x + if self.hexagonal: + # 1 cell less after every two rows + col -= col // (self.dim_x * 2 - 1) + return col + + +class SomCoordsCompute(SharedComputeValue): + def __init__(self, shared, column): + super().__init__(shared) + self.column = column + + def compute(self, _, shared_data): + return shared_data[0][:, self.column] + + def __eq__(self, other): + return self.column == other.column and super().__eq__(other) + + def __hash__(self): + return hash((super().__hash__(), self.column)) + + +class SomErrorCompute(SharedComputeValue): + InheritEq = True + + def compute(self, _, shared_data): + return shared_data[1] + + +class GetGroups: + # This assigns instances to selection groups; two instances that are not + # same cannot be considered equal, period. + InheritEq = True + + def __init__(self, id_to_group, default, offset): + self.id_to_group = id_to_group + self.default = default + offset + self.offset = offset + + def __call__(self, data, *args, **kwargs): + return np.array([self.id_to_group.get(id, self.default) - self.offset + for id in data.ids]) + + class OWSOM(OWWidget): name = "Self-Organizing Map" description = "Computation of self-organizing map." @@ -246,7 +357,8 @@ def __init__(self): self.stop_optimization = False self.data = self.cont_x = None - self.cells = self.member_data = None + self.scales = self.offsets = None + self.som = self.cells = self.member_data = None self.selection = None # self.colors holds a palette or None when we need to draw same-colored @@ -335,33 +447,16 @@ def __init__(self): self.grid_cells = None self.legend = None + @staticmethod + def _cont_domain(data): + attrs = data.domain.attributes + cont_attrs = [var for var in attrs if var.is_continuous] + if not cont_attrs: + return None + return Domain(cont_attrs) + @Inputs.data def set_data(self, data): - def prepare_data(): - if len(cont_attrs) < len(attrs): - self.Warning.ignoring_disc_variables() - if len(cont_attrs) == 1: - self.Warning.single_attribute() - x = Table.from_table(Domain(cont_attrs), data).X - if sp.issparse(x): - self.data = data - self.cont_x = x.tocsr() - else: - mask = np.all(np.isfinite(x), axis=1) - if np.sum(mask) <= 1: - self.Error.not_enough_data() - else: - if np.all(mask): - self.data = data - self.cont_x = x.copy() - else: - self.data = data[mask] - self.cont_x = x[mask] - self.cont_x -= np.min(self.cont_x, axis=0)[None, :] - sums = np.sum(self.cont_x, axis=0)[None, :] - sums[sums == 0] = 1 - self.cont_x /= sums - def set_warnings(): missing = len(data) - len(self.data) if missing: @@ -370,24 +465,40 @@ def set_warnings(): cont_x = self.cont_x.copy() if self.cont_x is not None else None self.data = self.cont_x = None + self.offsets = self.scales = None + new_cont_x = None self.closeContext() self.clear_messages() if data is not None: - attrs = data.domain.attributes - cont_attrs = [var for var in attrs if var.is_continuous] - if not cont_attrs: + cont_domain = self._cont_domain(data) + if cont_domain is None: self.Error.no_numeric_variables() else: - prepare_data() + cont_attrs = cont_domain.attributes + if len(cont_attrs) < len(data.domain.attributes): + self.Warning.ignoring_disc_variables() + if len(cont_attrs) == 1: + self.Warning.single_attribute() + + new_cont_x, mask, self.offsets, self.scales \ + = SOM.prepare_data(data.transform(cont_domain).X) + rows = np.sum(mask) + if rows == len(mask): + self.data = data + elif rows > 1: + self.data = data[mask] + else: + self.Error.not_enough_data() - invalidated = cont_x is None or self.cont_x is None \ - or not array_equal(cont_x, self.cont_x) + invalidated = cont_x is None or new_cont_x is None \ + or not array_equal(cont_x, new_cont_x) if invalidated: self.stop_optimization_and_wait() self.clear() if self.data is not None: + self.cont_x = new_cont_x self.controls.attr_color.model().set_domain(data.domain) self.attr_color = data.domain.class_var set_warnings() @@ -403,6 +514,7 @@ def set_warnings(): self._redraw() def clear(self): + self.som = self.cont_x = None self.cells = self.member_data = None self.attr_color = None self.colors = self.thresholds = self.bin_labels = None @@ -721,7 +833,7 @@ def _recompute_som(self): if self.cont_x is None: return - som = SOM( + self.som = SOM( self.size_x, self.size_y, hexagonal=self.hexagonal, pca_init=self.initialization == 0, @@ -760,7 +872,7 @@ def thread_finished(): self.progressBarInit() - self._optimizer = Optimizer(self.cont_x, som) + self._optimizer = Optimizer(self.cont_x, self.som) self._optimizer_thread = QThread() self._optimizer_thread.setStackSize(5 * 2 ** 20) self._optimizer.update.connect(self.__update) @@ -807,7 +919,7 @@ def onDeleteWidget(self): def _assign_instances(self, weights, ssum_weights): if self.cont_x is None: return # the widget is shutting down while signals still processed - assignments = SOM.winner_from_weights( + assignments, _ = SOM.winner_from_weights( self.cont_x, weights, ssum_weights, self.hexagonal) members = defaultdict(list) for i, (x, y) in enumerate(assignments): @@ -851,30 +963,78 @@ def rescale(self): self.size_y - 0.5 + leg_height / scale) def update_output(self): - if self.data is None: + if self.som is None: self.Outputs.selected_data.send(None) self.Outputs.annotated_data.send(None) return + ngroups = int(self.selection is not None) and np.max(self.selection) indices = np.zeros(len(self.data), dtype=int) + id_to_group = {} if self.selection is not None and np.any(self.selection): for y in range(self.size_y): for x in range(self.size_x): rows = self.get_member_indices(x, y) - indices[rows] = self.selection[x][y] + group = self.selection[x][y] + indices[rows] = group + if group > 0: + for id_ in self.data.ids[rows]: + id_to_group[id_] = group + + cont_domain = self._cont_domain(self.data) + shared_compute = SomSharedValueCompute( + cont_domain, self.som, self.offsets, self.scales) + cell = DiscreteVariable( + "som_cell", + values=tuple( + f"r{row + 1}c{col + 1}" + for row in range(self.size_y) + for col in range(self.size_x - (self.hexagonal and row % 2))), + compute_value=SomCellCompute(shared_compute, + self.size_x, self.hexagonal)) + coordx = ContinuousVariable( + "som_row", + number_of_decimals=0, + compute_value=SomCoordsCompute(shared_compute, 1)) + coordy = ContinuousVariable( + "som_col", + number_of_decimals=0, + compute_value=SomCoordsCompute(shared_compute, 0)) + error = ContinuousVariable( + "som_error", + compute_value=SomErrorCompute(shared_compute) + ) + som_attrs = (cell, coordx, coordy, error) + + grp_values, _ = group_values( + indices, include_unselected=False, values=None) + + def make_domain(values, default_grp, offset): + grp_var = DiscreteVariable( + get_unique_names(self.data.domain, ANNOTATED_DATA_FEATURE_NAME), + values, + compute_value=GetGroups(id_to_group, default_grp, offset)) + + if not self.data.domain.class_vars: + class_vars, metas = grp_var, som_attrs + else: + class_vars, metas = (), (grp_var,) + som_attrs + return add_columns(self.data.domain, (), class_vars, metas) if np.any(indices): - sel_data = create_groups_table(self.data, indices, False, "Group") - self.Outputs.selected_data.send(sel_data) + sel_domain = make_domain(grp_values, np.nan, 1) + mask = np.flatnonzero(indices) + self.Outputs.selected_data.send( + lazy_table_transform(sel_domain, self.data[mask])) else: self.Outputs.selected_data.send(None) - if np.max(indices) > 1: - annotated = create_groups_table(self.data, indices) + if ngroups > 1: + sel_domain = make_domain(grp_values + ["Unselected", ], ngroups, 1) else: - annotated = create_annotated_table( - self.data, np.flatnonzero(indices)) - self.Outputs.annotated_data.send(annotated) + sel_domain = make_domain(("No", "Yes"), 0, 0) + self.Outputs.annotated_data.send( + lazy_table_transform(sel_domain, self.data)) def set_color_bins(self): self.Warning.no_defined_colors.clear() diff --git a/Orange/widgets/unsupervised/tests/test_owsom.py b/Orange/widgets/unsupervised/tests/test_owsom.py index 9401db19ac7..776db304b24 100644 --- a/Orange/widgets/unsupervised/tests/test_owsom.py +++ b/Orange/widgets/unsupervised/tests/test_owsom.py @@ -12,7 +12,8 @@ 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 +from Orange.widgets.unsupervised.owsom import OWSOM, SomView, SOM, \ + SomSharedValueCompute, SomCellCompute, SomCoordsCompute, SomErrorCompute def _patch_recompute_som(meth): @@ -21,11 +22,14 @@ def winners_from_weights(cont_x, *_1, **_2): w = np.zeros((n, 2), dtype=int) w[n // 5:] = [0, 1] w[n // 3:] = [1, 2] - return w + return w, np.arange(n) / n def recompute(self): if not self.data: return + + self.som = Mock() + self.som.winners = winners_from_weights self._assign_instances(None, None) self._redraw() self.update_output() @@ -158,6 +162,7 @@ def test_sparse_data(self): self.assertTrue(sp.isspmatrix_csr(widget.cont_x)) self.assertEqual(widget.cont_x.shape, (150, 4)) + @_patch_recompute_som def test_auto_compute_dimensions(self): widget = self.widget self.send_signal(widget.Inputs.data, self.iris) @@ -455,6 +460,7 @@ def test_pie_charts(self): a = widget.elements.childItems()[0] np.testing.assert_equal(a.dist, [0.5, 0, 0, 0.5]) + @_patch_recompute_som def test_get_color_column(self): widget = self.widget @@ -479,7 +485,6 @@ def test_get_color_column(self): widget._get_color_column(), widget.data.get_column("gender").astype(int)) - # numeric attribute widget.thresholds = np.array([120, 150]) widget.attr_color = domain["max HR"] @@ -576,7 +581,7 @@ def test_on_selection_change_on_empty(self): widget.on_selection_change([]) @_patch_recompute_som - def test_output(self): + def test_output_selection(self): widget = self.widget self.send_signal(self.widget.Inputs.data, self.iris) @@ -586,6 +591,11 @@ def test_output(self): self.assertTrue( np.all(out.get_column(ANNOTATED_DATA_FEATURE_NAME) == 0)) + self.widget.cells = np.array( + [[[0, 30], [30, 50]] + [[50, 50]] * 6, + [[50, 50], [50, 50], [50, 150]] + [[150, 150]] * 5] + + [[[150, 150]] * 8] * 6) + m = np.zeros((widget.size_x, widget.size_y), dtype=bool) m[0, 0] = True widget.on_selection_change(m) @@ -612,6 +622,29 @@ def test_output(self): self.assertIsNone(self.get_output(widget.Outputs.selected_data)) self.assertIsNone(self.get_output(widget.Outputs.annotated_data)) + @_patch_recompute_som + def test_output_columns(self): + widget = self.widget + self.send_signal(self.widget.Inputs.data, self.iris) + + m = np.zeros((widget.size_x, widget.size_y), dtype=bool) + m[0, 0] = True + m[1, 2] = True + widget.on_selection_change(m) + + out = self.get_output(widget.Outputs.annotated_data) + + np.testing.assert_equal(out.get_column("som_row"), + [1] * 30 + [2] * 20 + [3] * 100) + np.testing.assert_equal(out.get_column("som_col"), + [1] * 50 + [2] * 100) + cell_var = out.domain["som_cell"] + np.testing.assert_equal([cell_var.repr_val(v) + for v in out.get_column("som_cell")], + ["r1c1"] * 30 + ["r2c1"] * 20 + ["r3c2"] * 100) + np.testing.assert_equal(out.get_column("som_error"), + np.arange(150) / 150) + def test_invalidated(self): heart = Table("heart_disease") self.widget._recompute_som = Mock() @@ -685,5 +718,49 @@ def test_modified_info(self): self.assertFalse(w.Information.modified.is_shown()) +class TestComputeValues(unittest.TestCase): + def test_eq_hash(self): + def equ(obj2): + self.assertEqual(obj1, obj2) + self.assertEqual(hash(obj1), hash(obj2)) + + def neq(obj2): + self.assertNotEqual(obj1, obj2) + self.assertNotEqual(hash(obj1), hash(obj2)) + + som1 = Mock() + som2 = Mock() + domain1 = Table("iris").domain + domain2 = Table("iris")[:, :4].domain + assert domain1 != domain2 + offsets1, scales1 = np.array([1, 2, 3]), np.array([4, 5, 6]) + offsets2, scales2 = np.array([2, 3, 4]), np.array([5, 6, 7]) + + shared1 = SomSharedValueCompute(domain1, som1, offsets1, scales1) + shared2 = SomSharedValueCompute(domain2, som1, offsets1, scales1) + + obj1 = shared1 + equ(SomSharedValueCompute(domain1, som1, offsets1, scales1)) + neq(shared2) + neq(SomSharedValueCompute(domain1, som2, offsets1, scales1)) + neq(SomSharedValueCompute(domain1, som1, offsets2, scales1)) + neq(SomSharedValueCompute(domain1, som1, offsets1, scales2)) + + obj1 = SomCellCompute(shared1, 8, False) + equ(SomCellCompute(shared1, 8, False)) + neq(SomCellCompute(shared2, 8, False)) + neq(SomCellCompute(shared1, 5, False)) + neq(SomCellCompute(shared1, 8, True)) + + obj1 = SomCoordsCompute(shared1, 0) + equ(SomCoordsCompute(shared1, 0)) + neq(SomCoordsCompute(shared2, 0)) + neq(SomCoordsCompute(shared1, 1)) + + obj1 = SomErrorCompute(shared1) + equ(SomErrorCompute(shared1)) + neq(SomErrorCompute(shared2)) + + if __name__ == "__main__": unittest.main() From e3f237153e4f6898d615be3d51dbcf2c6fb0a5d1 Mon Sep 17 00:00:00 2001 From: Marko Toplak Date: Fri, 1 Sep 2023 14:39:30 +0200 Subject: [PATCH 3/3] owsom: a single test that runs actual recompute_som Added result invaliation into the widget so that tests can get the outputs. --- Orange/widgets/unsupervised/owsom.py | 2 ++ Orange/widgets/unsupervised/tests/test_owsom.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/Orange/widgets/unsupervised/owsom.py b/Orange/widgets/unsupervised/owsom.py index 63f9dc3510d..84c11e31166 100644 --- a/Orange/widgets/unsupervised/owsom.py +++ b/Orange/widgets/unsupervised/owsom.py @@ -871,6 +871,7 @@ def thread_finished(): self._optimizer_thread = None self.progressBarInit() + self.setInvalidated(True) self._optimizer = Optimizer(self.cont_x, self.som) self._optimizer_thread = QThread() @@ -894,6 +895,7 @@ def __update(self, _progress, weights, ssum_weights): def __done(self, som): self.enable_controls(True) self.progressBarFinished() + self.setInvalidated(False) self._assign_instances(som.weights, som.ssum_weights) self._redraw() # This is the first time we know what was selected (assuming that diff --git a/Orange/widgets/unsupervised/tests/test_owsom.py b/Orange/widgets/unsupervised/tests/test_owsom.py index 776db304b24..ea7350d46bc 100644 --- a/Orange/widgets/unsupervised/tests/test_owsom.py +++ b/Orange/widgets/unsupervised/tests/test_owsom.py @@ -127,6 +127,12 @@ def test_missing_one_row_data(self): self.send_signal(widget.Inputs.data, None) self.assertFalse(widget.Warning.missing_values.is_shown()) + def test_run_actual_optimization(self): + # ther tests that compute something use _patch_recompute_som + self.send_signal(self.widget.Inputs.data, self.iris) + out = self.get_output(self.widget.Outputs.annotated_data) + self.assertEqual(len(out), 150) + @_patch_recompute_som def test_single_row_data(self): widget = self.widget