Repository: biolab/orange3

Issue:
Should we remove the Mahalanobis distance from the Distances widget?
Before on a meeting with @lanzagar and @BlazZupan we discussed that Mahalanobis distance should be removed from the Distance widget. The problem appears when someone accidentally clicks at this distance in the dropdown. On slightly bigger data Orange takes a long time to compute the Mahalanobis distances and there is no other option than to restart the Orange in order to continue with work.


Discussion:
What if the widget checked the dimensionality before computing, and if it's too high, it shows an error?

I'd like to keep the Mahalanobis distance for education purposes.
I think it is a good solution. 

Resolving patch:
diff --git a/Orange/widgets/unsupervised/owdistances.py b/Orange/widgets/unsupervised/owdistances.py
--- a/Orange/widgets/unsupervised/owdistances.py
+++ b/Orange/widgets/unsupervised/owdistances.py
@@ -16,7 +16,6 @@
 METRICS = [
     ("Euclidean", distance.Euclidean),
     ("Manhattan", distance.Manhattan),
-    ("Mahalanobis", distance.Mahalanobis),
     ("Cosine", distance.Cosine),
     ("Jaccard", distance.Jaccard),
     ("Spearman", distance.SpearmanR),
@@ -24,6 +23,7 @@
     ("Pearson", distance.PearsonR),
     ("Absolute Pearson", distance.PearsonRAbsolute),
     ("Hamming", distance.Hamming),
+    ("Mahalanobis", distance.Mahalanobis),
     ('Bhattacharyya', distance.Bhattacharyya)
 ]
 
@@ -83,6 +83,8 @@ class Error(OWWidget.Error):
         dense_metric_sparse_data = Msg("{} requires dense data.")
         distances_memory_error = Msg("Not enough memory")
         distances_value_error = Msg("Problem in calculation:\n{}")
+        data_too_large_for_mahalanobis = Msg(
+            "Mahalanobis handles up to 1000 {}.")
 
     class Warning(OWWidget.Warning):
         ignoring_discrete = Msg("Ignoring categorical features")
@@ -178,10 +180,23 @@ def _fix_missing():
                 data = distance.impute(data)
             return True
 
+        def _check_tractability():
+            if metric is distance.Mahalanobis:
+                if self.axis == 1:
+                    # when computing distances by columns, we want < 100 rows
+                    if len(data) > 1000:
+                        self.Error.data_too_large_for_mahalanobis("rows")
+                        return False
+                else:
+                    if len(data.domain.attributes) > 1000:
+                        self.Error.data_too_large_for_mahalanobis("columns")
+                        return False
+            return True
+
         self.clear_messages()
         if data is not None:
-            for check in (_check_sparse, _fix_discrete,
-                          _fix_missing, _fix_nonbinary):
+            for check in (_check_sparse, _check_tractability,
+                          _fix_discrete, _fix_missing, _fix_nonbinary):
                 if not check():
                     data = None
                     break
@@ -231,6 +246,13 @@ def migrate_settings(cls, settings, version):
             # normalize_dist is set to False when restoring settings from
             # an older version to preserve old semantics.
             settings["normalized_dist"] = False
+        if version is None or version < 3:
+            # Mahalanobis was moved from idx = 2 to idx = 9
+            metric_idx = settings["metric_idx"]
+            if metric_idx == 2:
+                settings["metric_idx"] = 9
+            elif 2 < metric_idx <= 9:
+                settings["metric_idx"] -= 1
 
 
 if __name__ == "__main__":  # pragma: no cover


Test patch:
diff --git a/Orange/widgets/unsupervised/tests/test_owdistances.py b/Orange/widgets/unsupervised/tests/test_owdistances.py
--- a/Orange/widgets/unsupervised/tests/test_owdistances.py
+++ b/Orange/widgets/unsupervised/tests/test_owdistances.py
@@ -6,7 +6,7 @@
 import numpy as np
 
 from Orange import distance
-from Orange.data import Table, Domain
+from Orange.data import Table, Domain, ContinuousVariable
 from Orange.misc import DistMatrix
 from Orange.widgets.unsupervised.owdistances import OWDistances, METRICS, \
     DistanceRunner
@@ -180,6 +180,54 @@ def test_negative_values_bhattacharyya(self):
         self.assertTrue(self.widget.Error.distances_value_error.is_shown())
         self.iris.X[0, 0] *= -1
 
+    def test_limit_mahalanobis(self):
+        def assert_error_shown():
+            self.assertTrue(
+                self.widget.Error.data_too_large_for_mahalanobis.is_shown())
+
+        def assert_no_error():
+            self.assertFalse(
+                self.widget.Error.data_too_large_for_mahalanobis.is_shown())
+
+        widget = self.widget
+        axis_buttons = widget.controls.axis.buttons
+
+        self.assertEqual(widget.metrics_combo.count(), len(METRICS))
+        for i, (_, metric) in enumerate(METRICS):
+            if metric == distance.Mahalanobis:
+                widget.metrics_combo.setCurrentIndex(i)
+                widget.metrics_combo.activated.emit(i)
+                break
+
+        X = np.random.random((1010, 4))
+        bigrows = Table.from_numpy(Domain(self.iris.domain.attributes), X)
+        bigcols = Table.from_numpy(
+            Domain([ContinuousVariable(f"{i}") for i in range(1010)]), X.T)
+
+        self.send_signal(widget.Inputs.data, self.iris)
+        assert_no_error()
+
+        axis_buttons[0].click()
+        assert_no_error()
+        axis_buttons[1].click()
+        assert_no_error()
+
+        # by columns -- cannot handle too many rows
+        self.send_signal(self.widget.Inputs.data, bigrows)
+        assert_error_shown()
+        axis_buttons[0].click()
+        assert_no_error()
+        axis_buttons[1].click()
+        assert_error_shown()
+
+        self.send_signal(self.widget.Inputs.data, bigcols)
+        assert_no_error()
+        axis_buttons[0].click()
+        assert_error_shown()
+
+        self.send_signal(widget.Inputs.data, self.iris)
+        assert_no_error()
+
 
 if __name__ == "__main__":
     unittest.main()