diff --git a/Orange/widgets/unsupervised/owtsne.py b/Orange/widgets/unsupervised/owtsne.py index e73937816a8..38e043ae67b 100644 --- a/Orange/widgets/unsupervised/owtsne.py +++ b/Orange/widgets/unsupervised/owtsne.py @@ -230,6 +230,24 @@ def update_coordinates(self): self.view_box.setAspectLocked(True, 1) +class invalidated: + pca_projection = affinities = tsne_embedding = False + + def __set__(self, instance, value): + # `self._invalidate = True` should invalidate everything + self.pca_projection = self.affinities = self.tsne_embedding = value + + def __bool__(self): + # If any of the values are invalidated, this should return true + return self.pca_projection or self.affinities or self.tsne_embedding + + def __str__(self): + return "%s(%s)" % (self.__class__.__name__, ", ".join( + "=".join([k, str(getattr(self, k))]) + for k in ["pca_projection", "affinities", "tsne_embedding"] + )) + + class OWtSNE(OWDataProjectionWidget, ConcurrentWidgetMixin): name = "t-SNE" description = "Two-dimensional data projection with t-SNE." @@ -250,6 +268,11 @@ class OWtSNE(OWDataProjectionWidget, ConcurrentWidgetMixin): left_side_scrolling = True + # Use `invalidated` descriptor so we don't break the usage of + # `_invalidated` in `OWDataProjectionWidget`, but still allow finer control + # over which parts of the embedding to invalidate + _invalidated = invalidated() + class Information(OWDataProjectionWidget.Information): modified = Msg("The parameter settings have been changed. Press " "\"Start\" to rerun with the new settings.") @@ -323,21 +346,19 @@ def _multiscale_changed(self): self._invalidate_affinities() def _invalidate_pca_projection(self): - self.pca_projection = None - self.initialization = None + self._invalidated.pca_projection = True self._invalidate_affinities() def _invalidate_affinities(self): - self.affinities = None + self._invalidated.affinities = True self._invalidate_tsne_embedding() def _invalidate_tsne_embedding(self): - self.iterations_done = 0 - self.tsne_embedding = None - self._invalidate_output() + self._invalidated.tsne_embedding = True + self._stop_running_task() self._set_modified(True) - def _invalidate_output(self): + def _stop_running_task(self): self.cancel() self.run_button.setText("Start") @@ -397,8 +418,15 @@ def _toggle_run(self): else: self.run() - def set_data(self, data: Table): - super().set_data(data) + def handleNewSignals(self): + # We don't bother with the granular invalidation flags because + # `super().handleNewSignals` will just set all of them to False or will + # do nothing. However, it's important we remember its state because we + # won't call `run` if needed. `run` also relies on the state of + # `_invalidated` to properly set the intermediate values to None + prev_invalidated = bool(self._invalidated) + super().handleNewSignals() + self._invalidated = prev_invalidated if self._invalidated: self.run() @@ -439,7 +467,17 @@ def enable_controls(self): self.controls.perplexity.setDisabled(self.multiscale) def run(self): + # Reset invalidated values as indicated by the flags + if self._invalidated.pca_projection: + self.pca_projection = None + if self._invalidated.affinities: + self.affinities = None + if self._invalidated.tsne_embedding: + self.iterations_done = 0 + self.tsne_embedding = None + self._set_modified(False) + self._invalidated = False # When the data is invalid, it is set to `None` and an error is set, # therefore it would be erroneous to clear the error here diff --git a/Orange/widgets/unsupervised/tests/test_owtsne.py b/Orange/widgets/unsupervised/tests/test_owtsne.py index 4dbb554d1ff..d8c2e680b77 100644 --- a/Orange/widgets/unsupervised/tests/test_owtsne.py +++ b/Orange/widgets/unsupervised/tests/test_owtsne.py @@ -250,6 +250,58 @@ def test_global_structure_info_msg_persists_if_data_is_reloaded(self): "The information message was cleared after data was reloaded" ) + def test_invalidation_flow(self): + w = self.widget + # Setup widget: send data to input with global structure "off", then + # set global structure "on" (after the embedding is computed) + w.controls.multiscale.setChecked(False) + self.send_signal(w.Inputs.data, self.data) + self.wait_until_stop_blocking() + self.assertFalse(self.widget.Information.modified.is_shown()) + # All the embedding components should computed + self.assertIsNotNone(w.pca_projection) + self.assertIsNotNone(w.affinities) + self.assertIsNotNone(w.tsne_embedding) + # All the invalidation flags should be set to false + self.assertFalse(w._invalidated.pca_projection) + self.assertFalse(w._invalidated.affinities) + self.assertFalse(w._invalidated.tsne_embedding) + + # Trigger invalidation + w.controls.multiscale.setChecked(True) + self.assertTrue(self.widget.Information.modified.is_shown()) + # Setting `multiscale` to true should set the invalidate flags for + # the affinities and embedding, but not the pca_projection + self.assertFalse(w._invalidated.pca_projection) + self.assertTrue(w._invalidated.affinities) + self.assertTrue(w._invalidated.tsne_embedding) + + # The flags should now be set, but the embedding should still be + # available when selecting a subset of data and such + self.assertIsNotNone(w.pca_projection) + self.assertIsNotNone(w.affinities) + self.assertIsNotNone(w.tsne_embedding) + + # We should still be able to send a data subset to the input and have + # the points be highlighted + self.send_signal(w.Inputs.data_subset, self.data[:10]) + self.wait_until_stop_blocking() + subset = [brush.color().name() == "#46befa" for brush in + w.graph.scatterplot_item.data["brush"][:10]] + other = [brush.color().name() == "#000000" for brush in + w.graph.scatterplot_item.data["brush"][10:]] + self.assertTrue(all(subset)) + self.assertTrue(all(other)) + + # Clear the data subset + self.send_signal(w.Inputs.data_subset, None) + + # Run the optimization + self.widget.run_button.clicked.emit() + self.wait_until_stop_blocking() + # All of the inavalidation flags should have been cleared + self.assertFalse(w._invalidated) + class TestTSNERunner(unittest.TestCase): @classmethod