diff --git a/Orange/widgets/unsupervised/owdistances.py b/Orange/widgets/unsupervised/owdistances.py index 184722c0c9c..f8f9e189f24 100644 --- a/Orange/widgets/unsupervised/owdistances.py +++ b/Orange/widgets/unsupervised/owdistances.py @@ -34,9 +34,16 @@ class Inputs: class Outputs: distances = Output("Distances", Orange.misc.DistMatrix, dynamic=False) - axis = settings.Setting(0) - metric_idx = settings.Setting(0) - autocommit = settings.Setting(True) + settings_version = 2 + + axis = settings.Setting(0) # type: int + metric_idx = settings.Setting(0) # type: int + + #: Use normalized distances if the metric supports it. + #: The default is `True`, expect when restoring from old pre v2 settings + #: (see `migrate_settings`). + normalized_dist = settings.Setting(True) # type: bool + autocommit = settings.Setting(True) # type: bool want_main_area = False buttons_area_orientation = Qt.Vertical @@ -59,11 +66,21 @@ def __init__(self): gui.radioButtons(self.controlArea, self, "axis", ["Rows", "Columns"], box="Distances between", callback=self._invalidate ) - self.metrics_combo = gui.comboBox(self.controlArea, self, "metric_idx", - box="Distance Metric", - items=[m[0] for m in METRICS], - callback=self._invalidate - ) + box = gui.widgetBox(self.controlArea, "Distance Metric") + self.metrics_combo = gui.comboBox( + box, self, "metric_idx", + items=[m[0] for m in METRICS], + callback=self._metric_changed + ) + self.normalization_check = gui.checkBox( + box, self, "normalized_dist", "Normalized", + callback=self._invalidate, + tooltip=("All dimensions are (implicitly) scaled to a common" + "scale to normalize the influence across the domain.") + ) + _, metric = METRICS[self.metric_idx] + self.normalization_check.setEnabled(metric.supports_normalization) + gui.auto_commit(self.controlArea, self, "autocommit", "Apply") self.layout().setSizeConstraint(self.layout().SetFixedSize) @@ -118,7 +135,11 @@ def _fix_missing(): if check() is False: return try: - return metric(data, axis=1 - self.axis, impute=True) + if metric.supports_normalization and self.normalized_dist: + return metric(data, axis=1 - self.axis, impute=True, + normalize=True) + else: + return metric(data, axis=1 - self.axis, impute=True) except ValueError as e: self.Error.distances_value_error(e) except MemoryError: @@ -127,9 +148,21 @@ def _fix_missing(): def _invalidate(self): self.commit() + def _metric_changed(self): + metric = METRICS[self.metric_idx][1] + self.normalization_check.setEnabled(metric.supports_normalization) + self._invalidate() + def send_report(self): # pylint: disable=invalid-sequence-index self.report_items(( ("Distances Between", ["Rows", "Columns"][self.axis]), ("Metric", METRICS[self.metric_idx][0]) )) + + @classmethod + def migrate_settings(cls, settings, version): + if version is None or version < 2 and "normalized_dist" not in settings: + # normalize_dist is set to False when restoring settings from + # an older version to preserve old semantics. + settings["normalized_dist"] = False diff --git a/Orange/widgets/unsupervised/tests/test_owdistances.py b/Orange/widgets/unsupervised/tests/test_owdistances.py index d54f9c49147..a67450a9b91 100644 --- a/Orange/widgets/unsupervised/tests/test_owdistances.py +++ b/Orange/widgets/unsupervised/tests/test_owdistances.py @@ -27,8 +27,13 @@ def test_distance_combo(self): self.widget.metrics_combo.activated.emit(i) self.widget.metrics_combo.setCurrentIndex(i) self.send_signal(self.widget.Inputs.data, self.iris) + if metric.supports_normalization: + expected = metric(self.iris, normalize=self.widget.normalized_dist) + else: + expected = metric(self.iris) + np.testing.assert_array_equal( - metric(self.iris), self.get_output(self.widget.Outputs.distances)) + expected, self.get_output(self.widget.Outputs.distances)) def test_error_message(self): """Check if error message appears and then disappears when @@ -58,3 +63,7 @@ def test_too_big_array(self): self.widget.compute_distances(mock, self.iris) self.assertEqual(len(self.widget.Error.active), 1) self.assertTrue(self.widget.Error.distances_memory_error.is_shown()) + + def test_migrates_normalized_dist(self): + w = self.create_widget(OWDistances, stored_settings={"metric_idx": 0}) + self.assertFalse(w.normalized_dist)