Repository: biolab/orange3

Issue:
Feature Constructor: allow creating meta variables directly
**What's your use case?**
Sometimes a user wants to construct a numeric feature that is not used for modeling but only for display. I needed that feature quite a few times.

**What's your proposed solution?**
A user could choose where to put the feature (attributes, metas, classes) in the Feature constructor. That would require an additional combo box.

**Are there any alternative solutions?**
Currently, a Select Columns can be used after Feature Constructor to move the feature.




Discussion:
In discussion, we said that classes may not be the most useful place (also because we discourage having multiple classes). This allows for using a (lighter) checkbox instead of a (heavier) combo.

I would suggest that the list contains only an indicator of where the variable will go. For changing it, the user would need to select the row and click the checkbox. This is consistent with how the widget works currently.

(In the future, the widget may be changed so that all editing is done within the table.)

Resolving patch:
diff --git a/Orange/widgets/data/owfeatureconstructor.py b/Orange/widgets/data/owfeatureconstructor.py
--- a/Orange/widgets/data/owfeatureconstructor.py
+++ b/Orange/widgets/data/owfeatureconstructor.py
@@ -26,9 +26,10 @@
 import numpy as np
 
 from AnyQt.QtWidgets import (
-    QSizePolicy, QAbstractItemView, QComboBox, QFormLayout, QLineEdit,
+    QSizePolicy, QAbstractItemView, QComboBox, QLineEdit,
     QHBoxLayout, QVBoxLayout, QStackedWidget, QStyledItemDelegate,
-    QPushButton, QMenu, QListView, QFrame, QLabel, QMessageBox)
+    QPushButton, QMenu, QListView, QFrame, QLabel, QMessageBox,
+    QGridLayout, QWidget, QCheckBox)
 from AnyQt.QtGui import QKeySequence
 from AnyQt.QtCore import Qt, pyqtSignal as Signal, pyqtProperty as Property
 
@@ -52,19 +53,26 @@
 
 
 FeatureDescriptor = \
-    namedtuple("FeatureDescriptor", ["name", "expression"])
+    namedtuple("FeatureDescriptor", ["name", "expression", "meta"],
+               defaults=[False])
 
 ContinuousDescriptor = \
     namedtuple("ContinuousDescriptor",
-               ["name", "expression", "number_of_decimals"])
+               ["name", "expression", "number_of_decimals", "meta"],
+               defaults=[False])
 DateTimeDescriptor = \
     namedtuple("DateTimeDescriptor",
-               ["name", "expression"])
+               ["name", "expression", "meta"],
+               defaults=[False])
 DiscreteDescriptor = \
     namedtuple("DiscreteDescriptor",
-               ["name", "expression", "values", "ordered"])
+               ["name", "expression", "values", "ordered", "meta"],
+               defaults=[False])
 
-StringDescriptor = namedtuple("StringDescriptor", ["name", "expression"])
+StringDescriptor = \
+    namedtuple("StringDescriptor",
+               ["name", "expression", "meta"],
+               defaults=[True])
 
 
 def make_variable(descriptor, compute_value):
@@ -130,15 +138,16 @@ class FeatureEditor(QFrame):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        layout = QFormLayout(
-            fieldGrowthPolicy=QFormLayout.ExpandingFieldsGrow
-        )
+        layout = QGridLayout()
         layout.setContentsMargins(0, 0, 0, 0)
         self.nameedit = QLineEdit(
             placeholderText="Name...",
             sizePolicy=QSizePolicy(QSizePolicy.Minimum,
                                    QSizePolicy.Fixed)
         )
+
+        self.metaattributecb = QCheckBox("Meta attribute")
+
         self.expressionedit = QLineEdit(
             placeholderText="Expression...",
             toolTip=self.ExpressionTooltip)
@@ -165,15 +174,17 @@ def __init__(self, *args, **kwargs):
             sizePolicy=QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum))
         self.functionscb.setModel(self.funcs_model)
 
-        hbox = QHBoxLayout()
-        hbox.addWidget(self.attributescb)
-        hbox.addWidget(self.functionscb)
+        layout.addWidget(self.nameedit, 0, 0)
+        layout.addWidget(self.metaattributecb, 1, 0)
+        layout.addWidget(self.expressionedit, 0, 1, 1, 2)
+        layout.addWidget(self.attributescb, 1, 1)
+        layout.addWidget(self.functionscb, 1, 2)
+        layout.addWidget(QWidget(), 2, 0)
 
-        layout.addRow(self.nameedit, self.expressionedit)
-        layout.addRow(self.tr(""), hbox)
         self.setLayout(layout)
 
         self.nameedit.editingFinished.connect(self._invalidate)
+        self.metaattributecb.clicked.connect(self._invalidate)
         self.expressionedit.textChanged.connect(self._invalidate)
         self.attributescb.currentIndexChanged.connect(self.on_attrs_changed)
         self.functionscb.currentIndexChanged.connect(self.on_funcs_changed)
@@ -196,6 +207,7 @@ def modified(self):
 
     def setEditorData(self, data, domain):
         self.nameedit.setText(data.name)
+        self.metaattributecb.setChecked(data.meta)
         self.expressionedit.setText(data.expression)
         self.setModified(False)
         self.featureChanged.emit()
@@ -207,7 +219,8 @@ def setEditorData(self, data, domain):
 
     def editorData(self):
         return FeatureDescriptor(name=self.nameedit.text(),
-                                 expression=self.nameedit.text())
+                                 expression=self.nameedit.text(),
+                                 meta=self.metaattributecb.isChecked())
 
     def _invalidate(self):
         self.setModified(True)
@@ -251,8 +264,9 @@ class ContinuousFeatureEditor(FeatureEditor):
     def editorData(self):
         return ContinuousDescriptor(
             name=self.nameedit.text(),
+            expression=self.expressionedit.text(),
+            meta=self.metaattributecb.isChecked(),
             number_of_decimals=None,
-            expression=self.expressionedit.text()
         )
 
 
@@ -265,7 +279,8 @@ class DateTimeFeatureEditor(FeatureEditor):
     def editorData(self):
         return DateTimeDescriptor(
             name=self.nameedit.text(),
-            expression=self.expressionedit.text()
+            expression=self.expressionedit.text(),
+            meta=self.metaattributecb.isChecked(),
         )
 
 
@@ -286,7 +301,8 @@ def __init__(self, *args, **kwargs):
         layout = self.layout()
         label = QLabel(self.tr("Values (optional)"))
         label.setToolTip(tooltip)
-        layout.addRow(label, self.valuesedit)
+        layout.addWidget(label, 2, 0)
+        layout.addWidget(self.valuesedit, 2, 1, 1, 2)
 
     def setEditorData(self, data, domain):
         self.valuesedit.setText(
@@ -300,6 +316,7 @@ def editorData(self):
         values = tuple(filter(None, [v.replace(r"\,", ",").strip() for v in values]))
         return DiscreteDescriptor(
             name=self.nameedit.text(),
+            meta=self.metaattributecb.isChecked(),
             values=values,
             ordered=False,
             expression=self.expressionedit.text()
@@ -310,9 +327,15 @@ class StringFeatureEditor(FeatureEditor):
     ExpressionTooltip = "A string expression\n\n" \
                         + FeatureEditor.ExpressionTooltip
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.metaattributecb.setChecked(True)
+        self.metaattributecb.setDisabled(True)
+
     def editorData(self):
         return StringDescriptor(
             name=self.nameedit.text(),
+            meta=True,
             expression=self.expressionedit.text()
         )
 
@@ -503,7 +526,7 @@ class Outputs:
     descriptors = ContextSetting([])
     currentIndex = ContextSetting(-1)
     expressions_with_values = ContextSetting(False)
-    settings_version = 2
+    settings_version = 3
 
     EDITORS = [
         (ContinuousDescriptor, ContinuousFeatureEditor),
@@ -566,22 +589,22 @@ def generate_newname(fmt):
         cont = menu.addAction("Numeric")
         cont.triggered.connect(
             lambda: self.addFeature(
-                ContinuousDescriptor(generate_newname("X{}"), "", 3))
+                ContinuousDescriptor(generate_newname("X{}"), "", 3, meta=False))
         )
         disc = menu.addAction("Categorical")
         disc.triggered.connect(
             lambda: self.addFeature(
-                DiscreteDescriptor(generate_newname("D{}"), "", (), False))
+                DiscreteDescriptor(generate_newname("D{}"), "", (), False, meta=False))
         )
         string = menu.addAction("Text")
         string.triggered.connect(
             lambda: self.addFeature(
-                StringDescriptor(generate_newname("S{}"), ""))
+                StringDescriptor(generate_newname("S{}"), "", meta=True))
         )
         datetime = menu.addAction("Date/Time")
         datetime.triggered.connect(
             lambda: self.addFeature(
-                DateTimeDescriptor(generate_newname("T{}"), ""))
+                DateTimeDescriptor(generate_newname("T{}"), "", meta=False))
         )
 
         menu.addSeparator()
@@ -897,17 +920,15 @@ class Result:
 def run(data: Table, desc, use_values, task: TaskState) -> Result:
     if task.is_interruption_requested():
         raise CancelledError  # pragma: no cover
-    new_variables = construct_variables(desc, data, use_values)
+    new_variables, new_metas = construct_variables(desc, data, use_values)
     # Explicit cancellation point after `construct_variables` which can
     # already run `compute_value`.
     if task.is_interruption_requested():
         raise CancelledError  # pragma: no cover
-    attrs = [var for var in new_variables if var.is_primitive()]
-    metas = [var for var in new_variables if not var.is_primitive()]
     new_domain = Orange.data.Domain(
-        data.domain.attributes + tuple(attrs),
+        data.domain.attributes + new_variables,
         data.domain.class_vars,
-        metas=data.domain.metas + tuple(metas)
+        metas=data.domain.metas + new_metas
     )
     try:
         for variable in new_variables:
@@ -916,7 +937,7 @@ def run(data: Table, desc, use_values, task: TaskState) -> Result:
     finally:
         for variable in new_variables:
             variable.compute_value.mask_exceptions = True
-    return Result(data, attrs, metas)
+    return Result(data, new_variables, new_metas)
 
 
 def validate_exp(exp):
@@ -989,12 +1010,13 @@ def validate_exp(exp):
 def construct_variables(descriptions, data, use_values=False):
     # subs
     variables = []
+    metas = []
     source_vars = data.domain.variables + data.domain.metas
     for desc in descriptions:
         desc, func = bind_variable(desc, source_vars, data, use_values)
         var = make_variable(desc, func)
-        variables.append(var)
-    return variables
+        [variables, metas][desc.meta].append(var)
+    return tuple(variables), tuple(metas)
 
 
 def sanitized_name(name):


Test patch:
diff --git a/Orange/widgets/data/tests/test_owfeatureconstructor.py b/Orange/widgets/data/tests/test_owfeatureconstructor.py
--- a/Orange/widgets/data/tests/test_owfeatureconstructor.py
+++ b/Orange/widgets/data/tests/test_owfeatureconstructor.py
@@ -39,8 +39,8 @@ def test_construct_variables_discrete(self):
             [DiscreteDescriptor(name=name, expression=expression,
                                 values=values, ordered=True)]
         )
-        data = data.transform(Domain(list(data.domain.attributes) +
-                                     construct_variables(desc, data),
+        data = data.transform(Domain(data.domain.attributes +
+                                     construct_variables(desc, data)[0],
                                      data.domain.class_vars,
                                      data.domain.metas))
         self.assertTrue(isinstance(data.domain[name], DiscreteVariable))
@@ -57,8 +57,8 @@ def test_construct_variables_discrete_no_values(self):
             [DiscreteDescriptor(name=name, expression=expression,
                                 values=values, ordered=False)]
         )
-        data = data.transform(Domain(list(data.domain.attributes) +
-                                     construct_variables(desc, data),
+        data = data.transform(Domain(data.domain.attributes +
+                                     construct_variables(desc, data)[0],
                                      data.domain.class_vars,
                                      data.domain.metas))
         newvar = data.domain[name]
@@ -76,8 +76,8 @@ def test_construct_variables_continuous(self):
             [ContinuousDescriptor(name=name, expression=expression,
                                   number_of_decimals=2)]
         )
-        data = data.transform(Domain(list(data.domain.attributes) +
-                                     construct_variables(featuremodel, data),
+        data = data.transform(Domain(data.domain.attributes +
+                                     construct_variables(featuremodel, data)[0],
                                      data.domain.class_vars,
                                      data.domain.metas))
         self.assertTrue(isinstance(data.domain[name], ContinuousVariable))
@@ -92,8 +92,8 @@ def test_construct_variables_datetime(self):
         featuremodel = PyListModel(
             [DateTimeDescriptor(name=name, expression=expression)]
         )
-        data = data.transform(Domain(list(data.domain.attributes) +
-                                     construct_variables(featuremodel, data),
+        data = data.transform(Domain(data.domain.attributes +
+                                     construct_variables(featuremodel, data)[0],
                                      data.domain.class_vars,
                                      data.domain.metas))
         self.assertTrue(isinstance(data.domain[name], TimeVariable))
@@ -110,8 +110,8 @@ def test_construct_variables_string(self):
         )
         data = data.transform(Domain(data.domain.attributes,
                                      data.domain.class_vars,
-                                     list(data.domain.metas) +
-                                     construct_variables(desc, data)))
+                                     data.domain.metas +
+                                     construct_variables(desc, data)[1]))
         self.assertTrue(isinstance(data.domain[name], StringVariable))
         for i in range(3):
             self.assertEqual(data[i * 50, name],
@@ -126,13 +126,25 @@ def test_construct_numeric_names():
         desc = PyListModel(
             [ContinuousDescriptor(name="S",
                                   expression="_0_1 + _1",
+                                  meta=False,
                                   number_of_decimals=3)]
         )
-        nv = construct_variables(desc, data)
+        nv, _ = construct_variables(desc, data)
         ndata = data.transform(Domain(nv))
         np.testing.assert_array_equal(ndata.X[:, 0],
                                       data.X[:, :2].sum(axis=1))
 
+    def test_construct_placement(self):
+        domain = Domain([ContinuousVariable(x) for x in "ab"])
+        data = Table.from_numpy(domain, np.arange(4).reshape(2, 2))
+        desc = PyListModel(
+            [ContinuousDescriptor("x", "a + b", 1, False),
+             ContinuousDescriptor("y", "a + b", 1, True),
+             StringDescriptor("z", "a + b", True)])
+        attrs, metas = construct_variables(desc, data)
+        self.assertEqual([var.name for var in attrs], ["x"])
+        self.assertEqual([var.name for var in metas], ["y", "z"])
+
     @staticmethod
     def test_unicode_normalization():
         micro = "\u00b5"
@@ -146,10 +158,11 @@ def test_unicode_normalization():
         data = Table.from_numpy(domain, np.arange(5).reshape(5, 1))
         data = data.transform(Domain(data.domain.attributes,
                                      [],
-                                     construct_variables(desc, data)))
+                                     construct_variables(desc, data)[0]))
         np.testing.assert_equal(data.X, data.metas)
 
-    def test_transform_sparse(self):
+    @staticmethod
+    def test_transform_sparse():
         domain = Domain([ContinuousVariable("A")])
         desc = [
             ContinuousDescriptor(name="X", expression="A", number_of_decimals=2)
@@ -158,7 +171,7 @@ def test_transform_sparse(self):
         data = Table.from_numpy(domain, X)
         data_ = data.transform(Domain(data.domain.attributes,
                                       [],
-                                      construct_variables(desc, data)))
+                                      construct_variables(desc, data)[0]))
         np.testing.assert_equal(
             data.get_column_view(0)[0], data_.get_column_view(0)[0]
         )