Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] Edit Domain: Add support for ordered categorical variables #3535

Merged
merged 3 commits into from
Jan 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 58 additions & 12 deletions Orange/widgets/data/oweditdomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
QWidget, QListView, QTreeView, QVBoxLayout, QHBoxLayout, QFormLayout,
QToolButton, QLineEdit, QAction, QActionGroup, QStackedWidget, QGroupBox,
QStyledItemDelegate, QStyleOptionViewItem, QStyle, QSizePolicy, QToolTip,
QDialogButtonBox, QPushButton
QDialogButtonBox, QPushButton, QCheckBox
)
from AnyQt.QtGui import QStandardItemModel, QStandardItem, QKeySequence, QIcon
from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
Expand Down Expand Up @@ -51,6 +51,10 @@ class Categorical(
])): pass


class Ordered(Categorical):
pass


class Real(
NamedTuple("Real", [
("name", str),
Expand Down Expand Up @@ -130,8 +134,14 @@ def __call__(self, var):
return var._replace(annotations=self.annotations)


Transform = Union[Rename, CategoriesMapping, Annotate]
TransformTypes = (Rename, CategoriesMapping, Annotate)
class ChangeOrdered(NamedTuple("ChangeOrdered", [("ordered", bool)])):
"""
Change Categorical <-> Ordered
"""


Transform = Union[Rename, CategoriesMapping, Annotate, ChangeOrdered]
TransformTypes = (Rename, CategoriesMapping, Annotate, ChangeOrdered)


def deconstruct(obj):
Expand Down Expand Up @@ -465,7 +475,10 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
form = self.layout().itemAt(0)
assert isinstance(form, QFormLayout)

self.ordered_cb = QCheckBox(
"Ordered", self, toolTip="Is this an ordered categorical."
)
self.ordered_cb.toggled.connect(self._set_ordered)
#: A list model of discrete variable's values.
self.values_model = itemmodels.PyListModel(
flags=Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable
Expand Down Expand Up @@ -553,9 +566,12 @@ def __init__(self, *args, **kwargs):
hlayout.addStretch(10)
vlayout.addLayout(hlayout)

form.insertRow(1, "Values:", vlayout)
form.insertRow(1, "", self.ordered_cb)
form.insertRow(2, "Values:", vlayout)

QWidget.setTabOrder(self.name_edit, self.ordered_cb)
QWidget.setTabOrder(self.ordered_cb, self.values_edit)

QWidget.setTabOrder(self.name_edit, self.values_edit)
QWidget.setTabOrder(self.values_edit, button1)
QWidget.setTabOrder(button1, button2)
QWidget.setTabOrder(button2, button3)
Expand All @@ -566,11 +582,15 @@ def set_data(self, var, transform=()):
"""
Set the variable to edit.
"""
# pylint: disable=too-many-branches
super().set_data(var, transform)
tr = None # type: Optional[CategoriesMapping]
ordered = None # type: Optional[ChangeOrdered]
for tr_ in transform:
if isinstance(tr_, CategoriesMapping):
tr = tr_
if isinstance(tr_, ChangeOrdered):
ordered = tr_

items = []
if tr is not None:
Expand Down Expand Up @@ -622,6 +642,10 @@ def set_data(self, var, transform=()):
self.values_model.index(i, 0),
item
)
if ordered is not None:
self.ordered_cb.setChecked(ordered.ordered)
elif var is not None:
self.ordered_cb.setChecked(isinstance(var, Ordered))
self.add_new_item.actionGroup().setEnabled(var is not None)

def __categories_mapping(self):
Expand Down Expand Up @@ -657,6 +681,9 @@ def get_data(self):
if any(_1 != _2 or _2 != _3
for (_1, _2), _3 in zip_longest(mapping, var.categories)):
tr.append(CategoriesMapping(mapping))
ordered = self.ordered_cb.isChecked()
if ordered != isinstance(var, Ordered):
tr.append(ChangeOrdered(ordered))
return var, tr

def clear(self):
Expand Down Expand Up @@ -753,6 +780,10 @@ def _add_category(self):
view.edit(index)
self.on_values_changed()

def _set_ordered(self, ordered):
self.ordered_cb.setChecked(ordered)
self.variable_changed.emit()


class ContinuousVariableEditor(VariableEditor):
# TODO: enable editing of display format...
Expand All @@ -766,7 +797,7 @@ class TimeVariableEditor(VariableEditor):

def variable_icon(var):
# type: (Variable) -> QIcon
if isinstance(var, Categorical):
if isinstance(var, (Categorical, Ordered)):
return gui.attributeIconDict[1]
elif isinstance(var, Real):
return gui.attributeIconDict[2]
Expand Down Expand Up @@ -835,7 +866,7 @@ class OWEditDomain(widget.OWWidget):
description = "Rename variables, edit categories and variable annotations."
icon = "icons/EditDomain.svg"
priority = 3125
keywords = []
keywords = ["rename", "drop", "reorder", "order"]

class Inputs:
data = Input("Data", Orange.data.Table)
Expand Down Expand Up @@ -1037,7 +1068,7 @@ def open_editor(self, index):
tr = []

editors = {
Categorical: 0,
Categorical: 0, Ordered: 0,
Real: 1,
Time: 2,
String: 3
Expand Down Expand Up @@ -1248,7 +1279,7 @@ def i(text):
def text(text):
return "<span>{}</span>".format(escape(text))
assert trs
rename = annotate = catmap = None
rename = annotate = catmap = ordered = None

for tr in trs:
if isinstance(tr, Rename):
Expand All @@ -1257,10 +1288,17 @@ def text(text):
annotate = tr
elif isinstance(tr, CategoriesMapping):
catmap = tr
elif isinstance(tr, ChangeOrdered):
ordered = tr

if rename is not None:
header = "{} → {}".format(var.name, rename.name)
else:
header = var.name
if ordered is not None and ordered.ordered != isinstance(var, Ordered):
assert isinstance(var, (Categorical, Ordered))
header += " (changed to {})".format(
"ordered" if ordered.ordered else "unordered")
values_section = None
if catmap is not None:
values_section = ("Values", [])
Expand Down Expand Up @@ -1328,7 +1366,10 @@ def abstract(var):
if isinstance(var, Orange.data.DiscreteVariable):
values, base = var.values, var.base_value
base = values[base] if base >= 0 else None
return Categorical(var.name, tuple(values), base, annotations)
if var.ordered:
return Ordered(var.name, tuple(values), base, annotations)
else:
return Categorical(var.name, tuple(values), base, annotations)
elif isinstance(var, Orange.data.TimeVariable):
return Time(var.name, annotations)
elif isinstance(var, Orange.data.ContinuousVariable):
Expand Down Expand Up @@ -1360,16 +1401,20 @@ def apply_transform(var, trs):
@apply_transform.register(Orange.data.DiscreteVariable)
def apply_transform_discete(var, trs):
# type: (Orange.data.DiscreteVariable, ...) -> ...
# pylint: disable=too-many-branches
name, annotations = var.name, var.attributes
base_value = var.base_value
mapping = None
ordered = var.ordered
for tr in trs:
if isinstance(tr, Rename):
name = tr.name
elif isinstance(tr, CategoriesMapping):
mapping = tr.mapping
elif isinstance(tr, Annotate):
annotations = _parse_attributes(tr.annotations)
elif isinstance(tr, ChangeOrdered):
ordered = tr.ordered

source_values = var.values
if mapping is not None:
Expand Down Expand Up @@ -1399,7 +1444,8 @@ def positions(values):
else:
lookup = Identity(var)
variable = Orange.data.DiscreteVariable(
name, values=dest_values, base_value=base_value, compute_value=lookup
name, values=dest_values, base_value=base_value, compute_value=lookup,
ordered=ordered,
)
variable.attributes.update(annotations)
return variable
Expand Down
36 changes: 34 additions & 2 deletions Orange/widgets/data/tests/test_oweditdomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
OWEditDomain,
ContinuousVariableEditor, DiscreteVariableEditor, VariableEditor,
TimeVariableEditor, Categorical, Real, Time, String,
Rename, Annotate, CategoriesMapping, report_transform,
apply_transform
Rename, Annotate, CategoriesMapping, ChangeOrdered, report_transform,
apply_transform,
)
from Orange.widgets.data.owcolor import OWColor, ColorRole
from Orange.widgets.tests.base import WidgetTest, GuiTest
Expand Down Expand Up @@ -53,6 +53,12 @@ def test_categories_mapping(self):
self.assertIn("b", r)
self.assertIn("<s>", r)

def test_change_ordered(self):
var = Categorical("C", ("a", "b"), None, ())
tr = ChangeOrdered(True)
r = report_transform(var, [tr])
self.assertIn("ordered", r)


class TestOWEditDomain(WidgetTest):
def setUp(self):
Expand Down Expand Up @@ -166,6 +172,21 @@ def test_time_variable_preservation(self):
output = self.get_output(self.widget.Outputs.data)
self.assertEqual(str(table[0, 4]), str(output[0, 4]))

def test_change_ordered(self):
"""Test categorical ordered flag change"""
table = Table(Domain(
[DiscreteVariable("A", values=["a", "b"], ordered=True)]))
self.send_signal(self.widget.Inputs.data, table)
output = self.get_output(self.widget.Outputs.data)
self.assertTrue(output.domain[0].ordered)

editor = self.widget.findChild(DiscreteVariableEditor)
assert isinstance(editor, DiscreteVariableEditor)
editor.ordered_cb.setChecked(False)
self.widget.commit()
output = self.get_output(self.widget.Outputs.data)
self.assertFalse(output.domain[0].ordered)


class TestEditors(GuiTest):
def test_variable_editor(self):
Expand Down Expand Up @@ -217,6 +238,7 @@ def test_discrete_editor(self):
w.set_data(v)

self.assertEqual(w.name_edit.text(), v.name)
self.assertFalse(w.ordered_cb.isChecked())
self.assertEqual(w.labels_model.get_dict(), dict(v.annotations))
self.assertEqual(w.get_data(), (v, []))
w.set_data(None)
Expand All @@ -233,6 +255,11 @@ def test_discrete_editor(self):
w.grab() # run delegate paint method
self.assertEqual(w.get_data(), (v, [CategoriesMapping(mapping)]))

w.set_data(v, [CategoriesMapping(mapping), ChangeOrdered(True)])
self.assertTrue(w.ordered_cb.isChecked())
self.assertEqual(
w.get_data()[1], [CategoriesMapping(mapping), ChangeOrdered(True)]
)
# test selection/deselection in the view
w.set_data(v)
view = w.values_edit
Expand Down Expand Up @@ -299,6 +326,11 @@ def test_discrete_reorder(self):
DD.compute_value, Lookup(D, np.array([2, 3, 1, 0]))
)

def test_ordered_change(self):
D = DiscreteVariable("D", values=("a", "b"), ordered=True)
Do = apply_transform(D, [ChangeOrdered(False)])
self.assertFalse(Do.ordered)

def test_discrete_add_drop(self):
D = DiscreteVariable("D", values=("2", "3", "1", "0"), base_value=1)
mapping = (
Expand Down