Repository: biolab/orange3

Issue:
Create Instance: Enable nan
<!-- 
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. -->
Sometimes there's no data available for a given attribute. Or you'd like to demonstrate something with missing values.




**What's your proposed solution?**
<!-- Be specific, clear, and concise. -->
Add a checkbox to Create Instance to the right of the current view. Off by default. If enabled, the value of the attribute is set to nan.




**Are there any alternative solutions?**
?







Discussion:

Resolving patch:
diff --git a/Orange/widgets/data/owcreateinstance.py b/Orange/widgets/data/owcreateinstance.py
--- a/Orange/widgets/data/owcreateinstance.py
+++ b/Orange/widgets/data/owcreateinstance.py
@@ -1,4 +1,4 @@
-from typing import Optional, Callable, List, Union, Dict
+from typing import Optional, Callable, List, Union, Dict, Tuple
 from collections import namedtuple
 from functools import singledispatch
 
@@ -7,7 +7,7 @@
 from AnyQt.QtCore import Qt, QSortFilterProxyModel, QSize, QDateTime, \
     QModelIndex, Signal, QPoint, QRect, QEvent
 from AnyQt.QtGui import QStandardItemModel, QStandardItem, QIcon, QPainter, \
-    QColor
+    QColor, QValidator
 from AnyQt.QtWidgets import QLineEdit, QTableView, QSlider, \
     QComboBox, QStyledItemDelegate, QWidget, QDateTimeEdit, QHBoxLayout, \
     QDoubleSpinBox, QSizePolicy, QStyleOptionViewItem, QLabel, QMenu, QAction
@@ -51,28 +51,36 @@ def sizeHint(self):
 
 
 class DiscreteVariableEditor(VariableEditor):
-    valueChanged = Signal(int)
-
-    def __init__(self, parent: QWidget, items: List[str], callback: Callable):
+    def __init__(self, parent: QWidget, items: Tuple[str], callback: Callable):
         super().__init__(parent, callback)
         self._combo = QComboBox(
             parent,
             maximumWidth=180,
             sizePolicy=QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
         )
-        self._combo.addItems(items)
-        self._combo.currentIndexChanged.connect(self.valueChanged)
+        self._combo.addItems(items + ("?",))
+        self._combo.currentIndexChanged.connect(self.__on_index_changed)
         self.layout().addWidget(self._combo)
 
     @property
-    def value(self) -> int:
-        return self._combo.currentIndex()
+    def value(self) -> Union[int, float]:
+        return self._map_to_var_values()
 
     @value.setter
     def value(self, value: float):
+        if np.isnan(value):
+            value = self._combo.model().rowCount() - 1
         assert value == int(value)
         self._combo.setCurrentIndex(int(value))
 
+    def __on_index_changed(self):
+        self.valueChanged.emit(self._map_to_var_values())
+
+    def _map_to_var_values(self) -> Union[int, float]:
+        n_values = self._combo.model().rowCount() - 1
+        current = self._combo.currentIndex()
+        return current if current < n_values else np.nan
+
 
 class ContinuousVariableEditor(VariableEditor):
     MAX_FLOAT = 2147483647
@@ -106,6 +114,17 @@ def sizeHint(self) -> QSize:
                 size: QSize = super().sizeHint()
                 return QSize(size.width(), size.height() + 2)
 
+            def validate(self, text: str, pos: int) -> Tuple[int, str, int]:
+                state, text, pos = super().validate(text, pos)
+                if text == "":
+                    state = QValidator.Acceptable
+                return state, text, pos
+
+            def textFromValue(self, value):
+                if not np.isfinite(value):
+                    return "?"
+                return super().textFromValue(value)
+
         self._spin = DoubleSpinBox(
             parent,
             value=self._min_value,
@@ -188,7 +207,8 @@ def _apply_slider_value(self):
         self.value = self.__map_from_slider(self._slider.value())
 
     def _apply_spin_value(self):
-        self.value = self._spin.value()
+        value = self._spin.value()
+        self.value = value if np.isfinite(value) else np.nan
 
     def __round_value(self, value):
         return round(value, self._n_decimals)
@@ -364,7 +384,9 @@ def _(variable: TimeVariable, _: np.ndarray,
     return TimeVariableEditor(parent, variable, callback)
 
 
-def majority(values: np.ndarray) -> int:
+def majority(values: np.ndarray) -> Union[int, float]:
+    if all(np.isnan(values)):
+        return np.nan
     return np.bincount(values[~np.isnan(values)].astype(int)).argmax()
 
 
@@ -579,8 +601,6 @@ def _initialize_values(self, fun: str, indices: List[QModelIndex] = None):
                 values = self.reference.get_column_view(variable)[0]
                 if variable.is_primitive():
                     values = values.astype(float)
-                    if all(np.isnan(values)):
-                        continue
             else:
                 values = self.model.data(index, ValuesRole)
 


Test patch:
diff --git a/Orange/widgets/data/tests/test_owcreateinstance.py b/Orange/widgets/data/tests/test_owcreateinstance.py
--- a/Orange/widgets/data/tests/test_owcreateinstance.py
+++ b/Orange/widgets/data/tests/test_owcreateinstance.py
@@ -154,11 +154,9 @@ def test_missing_values_reference(self):
             reference[:] = np.nan
         self.send_signal(self.widget.Inputs.data, self.data)
         self.send_signal(self.widget.Inputs.reference, reference)
-        output1 = self.get_output(self.widget.Outputs.data)
-        buttons = self._get_init_buttons()
-        buttons[3].click()  # Input
+        self._get_init_buttons()[3].click()  # Input
         output2 = self.get_output(self.widget.Outputs.data)
-        self.assert_table_equal(output1, output2)
+        np.testing.assert_array_equal(output2.X[-1], np.full(4, np.nan))
 
     def test_saved_workflow(self):
         data = self.data
@@ -175,6 +173,23 @@ def test_saved_workflow(self):
         output2 = self.get_output(widget.Outputs.data)
         self.assert_table_equal(output1, output2)
 
+    def test_saved_workflow_missing_values(self):
+        data = self.data
+        with data.unlocked():
+            data.X[0, 0] = np.nan
+            data.Y[0] = np.nan
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.send_signal(self.widget.Inputs.reference, data[:1])
+
+        self._get_init_buttons()[3].click()  # Input
+        output1 = self.get_output(self.widget.Outputs.data)
+
+        settings = self.widget.settingsHandler.pack_data(self.widget)
+        widget = self.create_widget(OWCreateInstance, stored_settings=settings)
+        self.send_signal(widget.Inputs.data, data, widget=widget)
+        output2 = self.get_output(widget.Outputs.data)
+        self.assert_table_equal(output1, output2)
+
     def test_commit_once(self):
         self.widget.commit.now = self.widget.commit.deferred = Mock()
         self.send_signal(self.widget.Inputs.data, self.data)
@@ -223,7 +238,7 @@ def setUpClass(cls):
     def setUp(self):
         self.callback = Mock()
         self.editor = DiscreteVariableEditor(
-            self.parent, ["Foo", "Bar"], self.callback
+            self.parent, ("Foo", "Bar"), self.callback
         )
 
     def test_init(self):
@@ -245,6 +260,18 @@ def test_set_value(self):
         self.assertEqual(self.editor._combo.currentText(), "Bar")
         self.callback.assert_called_once()
 
+    def test_edit_missing_value(self):
+        self.editor._combo.setCurrentText("?")
+        self.assertTrue(np.isnan(self.editor.value))
+        self.assertEqual(self.editor._combo.currentText(), "?")
+        self.callback.assert_called_once()
+
+    def test_set_missing_value(self):
+        self.editor.value = 2
+        self.assertTrue(np.isnan(self.editor.value))
+        self.assertEqual(self.editor._combo.currentText(), "?")
+        self.callback.assert_called_once()
+
 
 class TestContinuousVariableEditor(GuiTest):
     @classmethod
@@ -324,6 +351,12 @@ def test_set_value(self):
         self.assertEqual(self.editor.value, value)
         self.callback.assert_called_once()
 
+    def test_set_missing_value(self):
+        self.editor.value = np.nan
+        self.assertEqual(self.editor._slider.value(), self.min_value * 10)
+        self.assertFalse(np.isfinite(self.editor._spin.value()))
+        self.assertFalse(np.isfinite(self.editor.value))
+
     def test_missing_values(self):
         var = ContinuousVariable("var")
         self.assertRaises(ValueError, ContinuousVariableEditor, self.parent,