Repository: biolab/orange3

Issue:
MDS: amount of explained variance
<!-- 
Thanks for taking the time to submit a feature request!
For the best chance at our team considering your request, please answer the following questions to the best of your ability.
-->

**What's your use case?**
<!-- In other words, what's your pain point? -->
<!-- Is your request related to a problem, or perhaps a frustration? -->
<!-- Tell us the story that led you to write this request. -->
I got a comment from a reviewer, that it would be useful if MDS showed the amount of explained variable. Given that most MDS projections start with PCA initialization, this could perhaps be useful.




**What's your proposed solution?**
<!-- Be specific, clear, and concise. -->
Add info on amount of explained variance. Perhaps to the status bar or to the info box below parameter settings.




**Are there any alternative solutions?**
Look for the same info with PCA widget?







Discussion:
Here's what I found as well:

> The amount of generalized variance explained by the MDS solution can be expressed as P2 or Mardia criteria. P2 is the ratio of the sum of the eigenvalues over the total sum of the eigenvalues. Mardia criteria squares the numerator and denominator of the P2 values. Both are scaled from 0 to 1, with values closer to 1.0 indicating a good fit.
MDS starts with distances - even if the widget is given a table, it computes distances. Projection is non-linear so even if the original data was table-based, one can't "map" original coordinates to projection. I don't see how one could define explained variance. But then, I may not be an expert in MDS.

Mardia (which I haven't known before) measures the goodness of fit. Scikit doesn't have it, but we could display stress. But I am not sure that Scikit computes stress when using stress majorization. Computing it during updates could be too slow. We could compute it at the end, though.

Resolving patch:
diff --git a/Orange/widgets/unsupervised/owmds.py b/Orange/widgets/unsupervised/owmds.py
--- a/Orange/widgets/unsupervised/owmds.py
+++ b/Orange/widgets/unsupervised/owmds.py
@@ -200,6 +200,7 @@ def __init__(self):
 
         self.embedding = None  # type: Optional[np.ndarray]
         self.effective_matrix = None  # type: Optional[DistMatrix]
+        self.stress = None
 
         self.size_model = self.gui.points_models[2]
         self.size_model.order = \
@@ -241,6 +242,8 @@ def _add_controls_optimization(self):
                 sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed),
                 callback=self.__refresh_rate_combo_changed),
             1, 1)
+        self.stress_label = QLabel("Kruskal Stress: -")
+        grid.addWidget(self.stress_label, 2, 0, 1, 3)
 
     def __refresh_rate_combo_changed(self):
         if self.task is not None:
@@ -392,6 +395,7 @@ def on_partial_result(self, result: Result):
             if need_update:
                 self.graph.update_coordinates()
                 self.graph.update_density()
+                self.update_stress()
 
     def on_done(self, result: Result):
         assert isinstance(result.embedding, np.ndarray)
@@ -399,10 +403,23 @@ def on_done(self, result: Result):
         self.embedding = result.embedding
         self.graph.update_coordinates()
         self.graph.update_density()
+        self.update_stress()
         self.run_button.setText("Start")
         self.step_button.setEnabled(True)
         self.commit.deferred()
 
+    def update_stress(self):
+        self.stress = self._compute_stress()
+        stress_val = "-" if self.stress is None else f"{self.stress:.3f}"
+        self.stress_label.setText(f"Kruskal Stress: {stress_val}")
+
+    def _compute_stress(self):
+        if self.embedding is None or self.effective_matrix is None:
+            return None
+        point_stress = self.get_stress(self.embedding, self.effective_matrix)
+        return np.sqrt(2 * np.sum(point_stress)
+                       / (np.sum(self.effective_matrix ** 2) or 1))
+
     def on_exception(self, ex: Exception):
         if isinstance(ex, MemoryError):
             self.Error.out_of_memory()
@@ -436,6 +453,7 @@ def jitter_coord(part):
         # (Random or PCA), restarting the optimization if necessary.
         if self.effective_matrix is None:
             self.graph.reset_graph()
+            self.update_stress()
             return
 
         X = self.effective_matrix
@@ -451,6 +469,8 @@ def jitter_coord(part):
         # restart the optimization if it was interrupted.
         if self.task is not None:
             self._run()
+        else:
+            self.update_stress()
 
     def handleNewSignals(self):
         self._initialize()
@@ -473,12 +493,12 @@ def setup_plot(self):
 
     def get_size_data(self):
         if self.attr_size == "Stress":
-            return self.stress(self.embedding, self.effective_matrix)
+            return self.get_stress(self.embedding, self.effective_matrix)
         else:
             return super().get_size_data()
 
     @staticmethod
-    def stress(X, distD):
+    def get_stress(X, distD):
         assert X.shape[0] == distD.shape[0] == distD.shape[1]
         D1_c = scipy.spatial.distance.pdist(X, metric="euclidean")
         D1 = scipy.spatial.distance.squareform(D1_c, checks=False)


Test patch:
diff --git a/Orange/widgets/unsupervised/tests/test_owmds.py b/Orange/widgets/unsupervised/tests/test_owmds.py
--- a/Orange/widgets/unsupervised/tests/test_owmds.py
+++ b/Orange/widgets/unsupervised/tests/test_owmds.py
@@ -1,5 +1,5 @@
 # Test methods with long descriptive names can omit docstrings
-# pylint: disable=missing-docstring
+# pylint: disable=missing-docstring,protected-access
 import os
 from itertools import chain
 import unittest
@@ -320,6 +320,27 @@ def test_matrix_columns_default_label(self):
         label_text = self.widget.controls.attr_label.currentText()
         self.assertEqual(label_text, "labels")
 
+    def test_update_stress(self):
+        w = self.widget
+        w.effective_matrix = np.array([[0, 4, 1],
+                                       [4, 0, 1],
+                                       [1, 1, 0]])  # sum of squares is 36
+        w.embedding = np.array([[0, 0],
+                                [0, 3],
+                                [4, 3]])
+        # dists [[0, 3, 5], diff [[0, 1, 4],  sqr  [[0, 1, 16],  sum = 52
+        #        [3, 0, 4],       [1, 0, 3],        [1, 0, 9],
+        #        [5, 4, 0]]       [4, 3, 0]]        [16, 9, 0]]
+        w.update_stress()
+        expected = np.sqrt(52 / 36)
+        self.assertAlmostEqual(w._compute_stress(), expected)
+        self.assertIn(f"{expected:.3f}", w.stress_label.text())
+
+        w.embedding = None
+        w.update_stress()
+        self.assertIsNone(w._compute_stress())
+        self.assertIn("-", w.stress_label.text())
+
 
 class TestOWMDSRunner(unittest.TestCase):
     @classmethod