Repository: biolab/orange3

Issue:
k-Means widget does not normalize numeric features
**Describe the bug**

k-means should normalize numeric data, e.g. scale by span or variance.

**To Reproduce**

The most embarassing way to demonstrate this during a lecture is to cluster the heart disease data into two clusters and then connect it to Distributions and show the distribution of cholesterol with the data split by cluster. It is obvious that cholesterol value splits the two clusters perfectly ... which happens because the span of values for cholesterol is larger than the span of any other attributes.

**Orange version:**

Any.

**Expected behavior**

Normalize by mean and variance. Or span. @lanzagar, @BlazZupan, others: opinions?

**Additional context**

Don't tell anyone about this.


Discussion:
Definitely not a bug, at least I am not aware of any definition/implementation of k-means that would transform the data first, before starting the main loop.
The obvious problem is that the space transformation depends very much on the data and it would not make sense to say a specific one is part of the method.

That said, our widgets are also obviously not just single methods but do often include many additional options. And you could argue that this would be a sensible thing to include (as an option!). Someone else could argue that we already have a Preprocess widget that solves this very problem and should definitely be at the forefront of someones mind, when dealing with features with different scales.

So I would say this is a feature request which needs discussion :)
Let us add a checkbox so that users are aware that the widget normalizes data.

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
@@ -12,6 +12,7 @@
 from Orange.clustering.kmeans import KMeansModel
 from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable
 from Orange.data.util import get_unique_names, array_equal
+from Orange.preprocess import Normalize
 from Orange.preprocess.impute import ReplaceUnknowns
 from Orange.widgets import widget, gui
 from Orange.widgets.settings import Setting
@@ -144,6 +145,7 @@ class Warning(widget.OWWidget.Warning):
     smart_init = Setting(0)  # KMeans++
     selection = Setting(None, schema_only=True)  # type: Optional[int]
     auto_commit = Setting(True)
+    normalize = Setting(True)
 
     settings_version = 2
 
@@ -196,6 +198,10 @@ def __init__(self):
             callback=self.update_to)
         gui.rubber(ftobox)
 
+        box = gui.vBox(self.controlArea, "Preprocessing")
+        gui.checkBox(box, self, "normalize", "Normalize columns",
+                     callback=self.invalidate)
+
         box = gui.vBox(self.controlArea, "Initialization")
         gui.comboBox(
             box, self, "smart_init", items=[m[0] for m in self.INIT_METHODS],
@@ -334,7 +340,7 @@ def __launch_tasks(self, ks):
         """Execute clustering in separate threads for all given ks."""
         futures = [self.__executor.submit(
             self._compute_clustering,
-            data=self.data,
+            data=Normalize()(self.data) if self.normalize else self.data,
             k=k,
             init=self.INIT_METHODS[self.smart_init][1],
             n_init=self.n_init,
@@ -348,7 +354,7 @@ def __launch_tasks(self, ks):
         watcher.doneAll.connect(self.__commit_finished)
 
         self.__task = Task(futures, watcher)
-        self.progressBarInit(processEvents=False)
+        self.progressBarInit()
         self.setBlocking(True)
 
     def cancel(self):
@@ -467,6 +473,8 @@ def select_row(self):
         self.send_data()
 
     def preproces(self, data):
+        if self.normalize:
+            data = Normalize()(data)
         for preprocessor in KMeans.preprocessors:  # use same preprocessors than
             data = preprocessor(data)
         return data


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
@@ -228,10 +228,10 @@ def test_centroids_domain_on_output(self):
         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}
+        out_names = {attr.name for attr in out_attrs}
         for attr in in_attrs:
             self.assertEqual(
-                id(attr) in out_ids, attr.is_continuous,
+                attr.name in out_names, attr.is_continuous,
                 f"at attribute '{attr.name}'"
             )
         self.assertEqual(
@@ -315,12 +315,22 @@ def test_select_best_row(self):
         widget = self.widget
         widget.k_from, widget.k_to = 2, 6
         widget.optimize_k = True
+        widget.normalize = False
         self.send_signal(self.widget.Inputs.data, Table("housing"), wait=5000)
         self.commit_and_wait()
         widget.update_results()
-        # for housing dataset best selection is 3 clusters, so row no. 1
+        # for housing dataset without normalization,
+        # the best selection is 3 clusters, so row no. 1
         self.assertEqual(widget.selected_row(), 1)
 
+        widget.normalize = True
+        self.send_signal(self.widget.Inputs.data, Table("housing"), wait=5000)
+        self.commit_and_wait()
+        widget.update_results()
+        # for housing dataset with normalization,
+        # the best selection is 2 clusters, so row no. 0
+        self.assertEqual(widget.selected_row(), 0)
+
         widget.clusterings = {k: "error" for k in range(2, 7)}
         widget.update_results()
         self.assertEqual(widget.selected_row(), None)