Repository: biolab/orange3

Issue:
[Feature Request]: Add options for 0,1 and -1,1 normalization in Preprocess
##### Feature
Add options to normalize to range [0,1] or [-1,1].

##### Use case
Great for comparing values in Timeseries add-on, e.g. when plotting two attributes against one another in Line Chart.
With current preprocessing:
<img width="727" alt="screen shot 2017-11-03 at 14 57 13" src="https://user-images.githubusercontent.com/12524972/32377256-4cc6983a-c0a7-11e7-8ae0-f332cb007d37.png">

Ideally, both attributes would be scaled to 0,1.






Discussion:
You want something like `Preprocess > Normalize > Scale by span` in this widget, with options for no scaling, or scaling to [0, 1] or [-1, 1]?
Scaling to [0, 1] or [-1, 1].

Current preprocessing doesn't allow comparing peaks in timeseries in case you want to align these. It's a bit off with scale by span.
Right, was just checking.

Out of scope for the widget, won't fix.
`Preprocess > Normalize > Scale by span` should scale you to [0, 1].
<img width="545" alt="screen shot 2017-11-03 at 16 52 48" src="https://user-images.githubusercontent.com/12524972/32383145-766fa2a2-c0b7-11e7-9ee5-d7107212d989.png">

How again is this scaled to [0,1]?

I have checked the procedure before with @lanzagar and @astaric and it was confirmed this is not yet implemented. This is an ORC, however you want to call it. No need to close it.


Resolving patch:
diff --git a/Orange/preprocess/normalize.py b/Orange/preprocess/normalize.py
--- a/Orange/preprocess/normalize.py
+++ b/Orange/preprocess/normalize.py
@@ -44,7 +44,6 @@ def normalize_by_sd(self, dist, var):
         avg, sd = (dist.mean(), dist.standard_deviation()) if dist.size else (0, 1)
         if sd == 0:
             sd = 1
-
         if self.center:
             compute_val = Norm(var, avg, 1 / sd)
         else:
diff --git a/Orange/widgets/data/owpreprocess.py b/Orange/widgets/data/owpreprocess.py
--- a/Orange/widgets/data/owpreprocess.py
+++ b/Orange/widgets/data/owpreprocess.py
@@ -29,7 +29,7 @@
 from Orange.widgets.utils.sql import check_sql_input
 from Orange.widgets.utils.widgetpreview import WidgetPreview
 from Orange.widgets.widget import Input, Output
-
+from Orange.preprocess import Normalize
 from Orange.widgets.data.utils.preprocess import (
     BaseEditor, blocked, StandardItemModel, DescriptionRole,
     ParametersRole, Controller, SequenceFlow
@@ -636,55 +636,72 @@ def enum_to_index(enum, key):
     enum key to its int position"""
     return list(enum).index(key)
 
-
 class Scale(BaseEditor):
-    NoCentering, CenterMean, CenterMedian = 0, 1, 2
-    NoScaling, ScaleBySD, ScaleBySpan = 0, 1, 2
+    CenterByMean, ScaleBySD, NormalizeBySD, NormalizeBySpan_ZeroBased, \
+        NormalizeSpan_NonZeroBased = 0, 1, 2, 3, 4
+
+    Names = {
+        NormalizeBySD: "Standardize to μ=0, σ²=1",
+        CenterByMean: "Center to μ=0",
+        ScaleBySD: "Scale to σ²=1",
+        NormalizeSpan_NonZeroBased: "Normalize to interval [-1, 1]",
+        NormalizeBySpan_ZeroBased: "Normalize to interval [0, 1]",
+    }
 
     def __init__(self, parent=None, **kwargs):
         super().__init__(parent, **kwargs)
         self.setLayout(QVBoxLayout())
 
-        form = QFormLayout()
-        self.__centercb = QComboBox()
-        self.__centercb.addItems(["No Centering", "Center by Mean",
-                                  "Center by Median"])
+        self.__method = Scale.NormalizeBySD
+        self.__group = group = QButtonGroup(self, exclusive=True)
+        group.buttonClicked.connect(self.__on_buttonClicked)
 
-        self.__scalecb = QComboBox()
-        self.__scalecb.addItems(["No scaling", "Scale by SD",
-                                 "Scale by span"])
+        for methodid in [self.NormalizeBySD, self.CenterByMean, self.ScaleBySD,
+                         self.NormalizeSpan_NonZeroBased,
+                         self.NormalizeBySpan_ZeroBased]:
+            text = self.Names[methodid]
+            rb = QRadioButton(text=text, checked=self.__method == methodid)
+            group.addButton(rb, methodid)
+            self.layout().addWidget(rb)
 
-        form.addRow("Center:", self.__centercb)
-        form.addRow("Scale:", self.__scalecb)
-        self.layout().addLayout(form)
-        self.__centercb.currentIndexChanged.connect(self.changed)
-        self.__scalecb.currentIndexChanged.connect(self.changed)
-        self.__centercb.activated.connect(self.edited)
-        self.__scalecb.activated.connect(self.edited)
+    def setMethod(self, method):
+        b = self.__group.button(method)
+        if b is not None:
+            b.setChecked(True)
+            self.__method = method
+            self.changed.emit()
 
     def setParameters(self, params):
-        center = params.get("center", _Scale.CenteringType.Mean)
-        scale = params.get("scale", _Scale.ScalingType.Std)
-        self.__centercb.setCurrentIndex(
-            enum_to_index(_Scale.CenteringType, center))
-        self.__scalecb.setCurrentIndex(
-            enum_to_index(_Scale.ScalingType, scale))
+        method = params.get("method", Scale.NormalizeBySD)
+        self.setMethod(method)
 
     def parameters(self):
-        return {"center": index_to_enum(_Scale.CenteringType,
-                                        self.__centercb.currentIndex()),
-                "scale": index_to_enum(_Scale.ScalingType,
-                                       self.__scalecb.currentIndex())}
+        return {"method": self.__method}
+
+    def __on_buttonClicked(self):
+        self.__method = self.__group.checkedId()
+        self.changed.emit()
+        self.edited.emit()
 
     @staticmethod
     def createinstance(params):
-        center = params.get("center", _Scale.CenteringType.Mean)
-        scale = params.get("scale", _Scale.ScalingType.Std)
-        return _Scale(center=center, scale=scale)
+        method = params.get("method", Scale.NormalizeBySD)
+        if method == Scale.CenterByMean:
+            return _Scale(_Scale.CenteringType.Mean,
+                          _Scale.ScalingType.NoScaling)
+        elif method == Scale.ScaleBySD:
+            return _Scale(_Scale.CenteringType.NoCentering,
+                          _Scale.ScalingType.Std)
+        elif method == Scale.NormalizeBySD:
+            return Normalize(norm_type=Normalize.NormalizeBySD)
+        elif method == Scale.NormalizeBySpan_ZeroBased:
+            return Normalize(norm_type=Normalize.NormalizeBySpan)
+        else:  # method == Scale.NormalizeSpan_NonZeroBased
+            return Normalize(norm_type=Normalize.NormalizeBySpan,
+                             zero_based=False)
 
     def __repr__(self):
-        return "{}, {}".format(self.__centercb.currentText(),
-                               self.__scalecb.currentText())
+        return self.Names[self.__method]
 
 
 class Randomize(BaseEditor):
@@ -946,6 +963,8 @@ class OWPreprocess(widget.OWWidget):
     priority = 2105
     keywords = ["process"]
 
+    settings_version = 2
+
     class Inputs:
         data = Input("Data", Orange.data.Table)
 
@@ -1213,6 +1232,25 @@ def saveSettings(self):
         self.storedsettings = self.save(self.preprocessormodel)
         super().saveSettings()
 
+    @classmethod
+    def migrate_settings(cls, settings, version):
+        if version < 2:
+            for action, params in settings["storedsettings"]["preprocessors"]:
+                if action == "orange.preprocess.scale":
+                    scale = center = None
+                    if "center" in params:
+                        center = params.pop("center").name
+                    if "scale" in params:
+                        scale = params.pop("scale").name
+                    migratable = {
+                        ("Mean", "NoScaling"): Scale.CenterByMean,
+                        ("NoCentering", "Std"): Scale.ScaleBySD,
+                        ("Mean", "Std"): Scale.NormalizeBySD,
+                        ("NoCentering", "Span"): Scale.NormalizeBySpan_ZeroBased
+                    }
+                    params["method"] = \
+                        migratable.get((center, scale), Scale.NormalizeBySD)
+
     def onDeleteWidget(self):
         self.data = None
         self.set_model(None)
@@ -1239,6 +1277,5 @@ def send_report(self):
         if len(pp):
             self.report_items("Settings", pp)
 
-
 if __name__ == "__main__":  # pragma: no cover
     WidgetPreview(OWPreprocess).run(Orange.data.Table("brown-selected"))


Test patch:
diff --git a/Orange/widgets/data/tests/test_owpreprocess.py b/Orange/widgets/data/tests/test_owpreprocess.py
--- a/Orange/widgets/data/tests/test_owpreprocess.py
+++ b/Orange/widgets/data/tests/test_owpreprocess.py
@@ -9,7 +9,7 @@
 from Orange.preprocess import discretize, impute, fss, score
 from Orange.widgets.data import owpreprocess
 from Orange.widgets.data.owpreprocess import OWPreprocess, \
-    UnivariateFeatureSelect
+    UnivariateFeatureSelect, Scale as ScaleEditor
 from Orange.widgets.tests.base import WidgetTest, datasets
 
 
@@ -40,16 +40,52 @@ def test_randomize(self):
     def test_normalize(self):
         data = Table("iris")
         saved = {"preprocessors": [("orange.preprocess.scale",
-                                    {"center": Scale.CenteringType.Mean,
-                                     "scale": Scale.ScalingType.Std})]}
+                                    {"method": ScaleEditor.NormalizeBySD})]}
         model = self.widget.load(saved)
         self.widget.set_model(model)
         self.send_signal(self.widget.Inputs.data, data)
         output = self.get_output(self.widget.Outputs.preprocessed_data)
+        np.testing.assert_allclose(output.X.mean(0), 0, atol=1e-7)
+        np.testing.assert_allclose(output.X.std(0), 1, atol=1e-7)
 
+        saved = {"preprocessors": [("orange.preprocess.scale",
+                                    {"method": ScaleEditor.CenterByMean})]}
+        model = self.widget.load(saved)
+        self.widget.set_model(model)
+        self.send_signal(self.widget.Inputs.data, data)
+        output = self.get_output(self.widget.Outputs.preprocessed_data)
         np.testing.assert_allclose(output.X.mean(0), 0, atol=1e-7)
+        self.assertTrue((output.X.std(0) > 0).all())
+
+        saved = {"preprocessors": [("orange.preprocess.scale",
+                                    {"method": ScaleEditor.ScaleBySD})]}
+        model = self.widget.load(saved)
+        self.widget.set_model(model)
+        self.send_signal(self.widget.Inputs.data, data)
+        output = self.get_output(self.widget.Outputs.preprocessed_data)
+        self.assertTrue((output.X.mean(0) != 0).all())
         np.testing.assert_allclose(output.X.std(0), 1, atol=1e-7)
 
+        saved = {"preprocessors":
+                 [("orange.preprocess.scale",
+                   {"method": ScaleEditor.NormalizeBySpan_ZeroBased})]}
+        model = self.widget.load(saved)
+        self.widget.set_model(model)
+        self.send_signal(self.widget.Inputs.data, data)
+        output = self.get_output(self.widget.Outputs.preprocessed_data)
+        self.assertAlmostEqual(output.X.min(), 0)
+        self.assertAlmostEqual(output.X.max(), 1)
+
+        saved = {"preprocessors":
+                 [("orange.preprocess.scale",
+                   {"method": ScaleEditor.NormalizeSpan_NonZeroBased})]}
+        model = self.widget.load(saved)
+        self.widget.set_model(model)
+        self.send_signal(self.widget.Inputs.data, data)
+        output = self.get_output(self.widget.Outputs.preprocessed_data)
+        self.assertAlmostEqual(output.X.min(), -1)
+        self.assertAlmostEqual(output.X.max(), 1)
+
     def test_select_features(self):
         data = Table("iris")
         saved = {"preprocessors": [("orange.preprocess.fss",