Repository: biolab/orange3

Issue:
K-means centroids output has no labels
K-means has an output containing cluster centroids. Plotting them in e.g. MDS (or doing whatever else with them) is problematic, since the user can't label the points and does not know which cluster they correspond to (to see for example which clusters are similar).

I propose we add a meta column "Cluster" with cluster ids "C1", "C2", ... just as in the default Data output from K-means. Up for discussion - for the sake of consistency we could add the Silhouette meta attribute too.





Discussion:
Also, this would be cool but doesn't work.

<img width="617" alt="Screenshot 2019-03-22 at 12 24 04" src="https://user-images.githubusercontent.com/2387315/54819947-d2b27d00-4c9d-11e9-978f-a923210475a2.png">

Attribute columns are doubled after concatenate. Either k-means (or its base class, projection) creates new attributes, or concatenate incorrectly computes the set.

Resolving patch:
diff --git a/Orange/widgets/unsupervised/owkmeans.py b/Orange/widgets/unsupervised/owkmeans.py
--- a/Orange/widgets/unsupervised/owkmeans.py
+++ b/Orange/widgets/unsupervised/owkmeans.py
@@ -11,6 +11,7 @@
 from Orange.clustering.kmeans import KMeansModel, SILHOUETTE_MAX_SAMPLES
 from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable
 from Orange.data.util import get_unique_names
+from Orange.preprocess.impute import ReplaceUnknowns
 from Orange.widgets import widget, gui
 from Orange.widgets.settings import Setting
 from Orange.widgets.utils.annotated_data import \
@@ -33,7 +34,8 @@ def __init__(self, parent=None):
     def rowCount(self, index=QModelIndex()):
         return 0 if index.isValid() else len(self.scores)
 
-    def columnCount(self, index=QModelIndex()):
+    @staticmethod
+    def columnCount(_index=QModelIndex()):
         return 1
 
     def flags(self, index):
@@ -64,10 +66,12 @@ def data(self, index, role=Qt.DisplayRole):
             return score
         elif role == gui.BarRatioRole and valid:
             return score
+        return None
 
-    def headerData(self, row, orientation, role=Qt.DisplayRole):
+    def headerData(self, row, _orientation, role=Qt.DisplayRole):
         if role == Qt.DisplayRole:
             return str(row + self.start_k)
+        return None
 
 
 class Task:
@@ -443,8 +447,9 @@ def update_results(self):
 
     def selected_row(self):
         indices = self.table_view.selectedIndexes()
-        if indices:
-            return indices[0].row()
+        if not indices:
+            return None
+        return indices[0].row()
 
     def select_row(self):
         self.send_data()
@@ -468,21 +473,49 @@ def send_data(self):
             values=["C%d" % (x + 1) for x in range(km.k)]
         )
         clust_ids = km(self.data)
+        clust_col = clust_ids.X.ravel()
         silhouette_var = ContinuousVariable(
             get_unique_names(domain, "Silhouette"))
         if km.silhouette_samples is not None:
             self.Warning.no_silhouettes.clear()
             scores = np.arctan(km.silhouette_samples) / np.pi + 0.5
+            clust_scores = []
+            for i in range(km.k):
+                in_clust = clust_col == i
+                if in_clust.any():
+                    clust_scores.append(np.mean(scores[in_clust]))
+                else:
+                    clust_scores.append(0.)
+            clust_scores = np.atleast_2d(clust_scores).T
         else:
             self.Warning.no_silhouettes()
             scores = np.nan
+            clust_scores = np.full((km.k, 1), np.nan)
 
         new_domain = add_columns(domain, metas=[cluster_var, silhouette_var])
         new_table = self.data.transform(new_domain)
-        new_table.get_column_view(cluster_var)[0][:] = clust_ids.X.ravel()
+        new_table.get_column_view(cluster_var)[0][:] = clust_col
         new_table.get_column_view(silhouette_var)[0][:] = scores
 
-        centroids = Table(Domain(km.pre_domain.attributes), km.centroids)
+        centroid_attributes = [
+            attr.compute_value.variable
+            if isinstance(attr.compute_value, ReplaceUnknowns)
+            and attr.compute_value.variable in domain.attributes
+            else attr
+            for attr in km.pre_domain.attributes]
+        centroid_domain = add_columns(
+            Domain(centroid_attributes, [], domain.metas),
+            metas=[cluster_var, silhouette_var])
+        centroids = Table(
+            centroid_domain, km.centroids, None,
+            np.hstack((np.full((km.k, len(domain.metas)), np.nan),
+                       np.arange(km.k).reshape(km.k, 1),
+                       clust_scores))
+        )
+        if self.data.name == Table.name:
+            centroids.name = "centroids"
+        else:
+            centroids.name = f"{self.data.name} centroids"
 
         self.Outputs.annotated_data.send(new_table)
         self.Outputs.centroids.send(centroids)


Test patch:
diff --git a/Orange/widgets/unsupervised/tests/test_owkmeans.py b/Orange/widgets/unsupervised/tests/test_owkmeans.py
--- a/Orange/widgets/unsupervised/tests/test_owkmeans.py
+++ b/Orange/widgets/unsupervised/tests/test_owkmeans.py
@@ -197,15 +197,60 @@ def test_data_on_output(self):
         # removing data should have cleared the output
         self.assertEqual(self.widget.data, None)
 
+    @patch("Orange.clustering.kmeans.KMeansModel.__call__")
+    def test_centroids_on_output(self, km_call):
+        ret = km_call.return_value = Mock()
+        ret.X = np.array([0] * 50 + [1] * 100)
+        ret.silhouette_samples = np.arange(150) / 150
+
+        widget = self.widget
+        widget.optimize_k = False
+        widget.k = 4
+        self.send_signal(widget.Inputs.data, self.iris)
+        self.commit_and_wait()
+
+        widget.clusterings[4].silhouette_samples = np.arange(150) / 150
+        widget.send_data()
+        out = self.get_output(widget.Outputs.centroids)
+        np.testing.assert_almost_equal(
+            out.metas,
+            [[0, np.mean(np.arctan(np.arange(50) / 150)) / np.pi + 0.5],
+             [1, np.mean(np.arctan(np.arange(50, 150) / 150)) / np.pi + 0.5],
+             [2, 0], [3, 0]])
+        self.assertEqual(out.name, "iris centroids")
+
+    def test_centroids_domain_on_output(self):
+        widget = self.widget
+        widget.optimize_k = False
+        widget.k = 4
+        heart_disease = Table("heart_disease")
+        heart_disease.name = Table.name  # untitled
+        self.send_signal(widget.Inputs.data, heart_disease)
+        self.commit_and_wait()
+
+        in_attrs = heart_disease.domain.attributes
+        out = self.get_output(widget.Outputs.centroids)
+        out_attrs = out.domain.attributes
+        out_ids = {id(attr) for attr in out_attrs}
+        for attr in in_attrs:
+            self.assertEqual(
+                id(attr) in out_ids, attr.is_continuous,
+                f"at attribute '{attr.name}'"
+            )
+        self.assertEqual(
+            len(out_attrs),
+            sum(attr.is_continuous or len(attr.values) for attr in in_attrs))
+        self.assertEqual(out.name, "centroids")
+
     class KMeansFail(Orange.clustering.KMeans):
         fail_on = set()
 
-        def fit(self, *args):
+        def fit(self, X, Y=None):
             # when not optimizing, params is empty?!
             k = self.params.get("n_clusters", 3)
             if k in self.fail_on:
                 raise ValueError("k={} fails".format(k))
-            return super().fit(*args)
+            return super().fit(X, Y)
 
     @patch("Orange.widgets.unsupervised.owkmeans.KMeans", new=KMeansFail)
     def test_optimization_fails(self):
@@ -346,7 +391,8 @@ def test_silhouette_column(self):
         widget.k = 4
         widget.optimize_k = False
 
-        random = np.random.RandomState(0)  # Avoid randomness in the test
+        # Avoid randomness in the test
+        random = np.random.RandomState(0)  # pylint: disable=no-member
         table = Table(random.rand(110, 2))
         with patch("Orange.clustering.kmeans.SILHOUETTE_MAX_SAMPLES", 100):
             self.send_signal(self.widget.Inputs.data, table)