source: orange/Orange/OrangeWidgets/Data/OWEditDomain.py @ 11432:4bc3d242eb5f

Revision 11432:4bc3d242eb5f, 21.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 13 months ago (diff)

Handle multitarget domain (class_vars).

RevLine 
[9320]1"""
2<name>Edit Domain</name>
3<description>Edit domain variables</description>
[11096]4<icon>icons/EditDomain.svg</icon>
[9320]5<contact>Ales Erjavec (ales.erjavec(@ at @)fri.uni-lj.si)</contact>
6<priority>3125</priority>
[9323]7<keywords>change,name,variable,domain</keywords>
[9320]8"""
9
10from OWWidget import *
11from OWItemModels import VariableListModel, PyListModel
12
13import OWGUI
14
15import Orange
16
[11430]17
[9320]18def is_discrete(var):
[10046]19    return isinstance(var, Orange.feature.Discrete)
[9320]20
[11430]21
[9320]22def is_continuous(var):
[10046]23    return isinstance(var, Orange.feature.Continuous)
[9320]24
[11430]25
[9320]26def get_qualified(module, name):
[11430]27    """Return a qualified module member ``name`` inside the named
[9323]28    ``module``.
[11430]29
[10358]30    The module (or package) first gets imported and the name
[9323]31    is retrieved from the module's global namespace.
[11430]32
[9323]33    """
[10358]34    # see __import__.__doc__ for why 'fromlist' is used
35    module = __import__(module, fromlist=[name])
[9320]36    return getattr(module, name)
37
[11430]38
[9320]39def variable_description(var):
[10358]40    """Return a variable descriptor.
[11430]41
42    A descriptor is a hashable tuple which should uniquely define
43    the variable i.e. (module, type_name, variable_name,
[10358]44    any_kwargs, sorted-attributes-items).
[11430]45
[9323]46    """
[9320]47    var_type = type(var)
48    if is_discrete(var):
49        return (var_type.__module__,
50                var_type.__name__,
[11430]51                var.name,
52                (("values", tuple(var.values)),),
[9320]53                tuple(sorted(var.attributes.items())))
54    else:
55        return (var_type.__module__,
56                var_type.__name__,
[11430]57                var.name,
58                (),
[9320]59                tuple(sorted(var.attributes.items())))
60
[11430]61
[9320]62def variable_from_description(description):
[11430]63    """Construct a variable from its description (see
64    :func:`variable_description`).
65
[9323]66    """
[9320]67    module, type_name, name, kwargs, attrs = description
[10358]68    try:
69        type = get_qualified(module, type_name)
70    except (ImportError, AttributeError), ex:
[11430]71        raise ValueError("Invalid descriptor type '{}.{}"
72                         "".format(module, type_name))
73
[9320]74    var = type(name, **dict(list(kwargs)))
75    var.attributes.update(attrs)
76    return var
[11430]77
[9320]78from PyQt4 import QtCore, QtGui
79
80QtCore.Slot = QtCore.pyqtSlot
81QtCore.Signal = QtCore.pyqtSignal
82
[11430]83
[9320]84class PyStandardItem(QStandardItem):
85    def __lt__(self, other):
86        return id(self) < id(other)
[11430]87
88
[9320]89class DictItemsModel(QStandardItemModel):
[10358]90    """A Qt Item Model class displaying the contents of a python
[9323]91    dictionary.
[11430]92
[9323]93    """
94    # Implement a proper model with in-place editing.
95    # (Maybe it should be a TableModel with 2 columns)
[9320]96    def __init__(self, parent=None, dict={}):
97        QStandardItemModel.__init__(self, parent)
98        self.setHorizontalHeaderLabels(["Key", "Value"])
99        self.set_dict(dict)
[11430]100
[9320]101    def set_dict(self, dict):
102        self._dict = dict
103        self.clear()
104        self.setHorizontalHeaderLabels(["Key", "Value"])
105        for key, value in sorted(dict.items()):
106            key_item = PyStandardItem(QString(key))
107            value_item = PyStandardItem(QString(value))
108            key_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
109            value_item.setFlags(value_item.flags() | Qt.ItemIsEditable)
110            self.appendRow([key_item, value_item])
[11430]111
[9320]112    def get_dict(self):
113        dict = {}
114        for row in range(self.rowCount()):
115            key_item = self.item(row, 0)
116            value_item = self.item(row, 1)
117            dict[str(key_item.text())] = str(value_item.text())
118        return dict
119
[11430]120
[9320]121class VariableEditor(QWidget):
[10358]122    """An editor widget for a variable.
[11430]123
[9323]124    Can edit the variable name, and its attributes dictionary.
[11430]125
[9323]126    """
[9320]127    def __init__(self, parent=None):
128        QWidget.__init__(self, parent)
129        self.setup_gui()
[11430]130
[9320]131    def setup_gui(self):
132        layout = QVBoxLayout()
133        self.setLayout(layout)
[11430]134
[9320]135        self.main_form = QFormLayout()
136        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
137        layout.addLayout(self.main_form)
[11430]138
[9320]139        self._setup_gui_name()
140        self._setup_gui_labels()
[11430]141
[9320]142    def _setup_gui_name(self):
143        self.name_edit = QLineEdit()
144        self.main_form.addRow("Name", self.name_edit)
145        self.name_edit.editingFinished.connect(self.on_name_changed)
[11430]146
[9320]147    def _setup_gui_labels(self):
[9323]148        vlayout = QVBoxLayout()
149        vlayout.setContentsMargins(0, 0, 0, 0)
150        vlayout.setSpacing(1)
[11430]151
[9320]152        self.labels_edit = QTreeView()
[10726]153        self.labels_edit.setEditTriggers(QTreeView.CurrentChanged)
[9323]154        self.labels_edit.setRootIsDecorated(False)
[11430]155
[9320]156        self.labels_model = DictItemsModel()
157        self.labels_edit.setModel(self.labels_model)
[11430]158
[11432]159        self.labels_edit.selectionModel().selectionChanged.connect(
160            self.on_label_selection_changed)
[11430]161
[9323]162        # Necessary signals to know when the labels change
[9320]163        self.labels_model.dataChanged.connect(self.on_labels_changed)
[9323]164        self.labels_model.rowsInserted.connect(self.on_labels_changed)
165        self.labels_model.rowsRemoved.connect(self.on_labels_changed)
[11430]166
[9323]167        vlayout.addWidget(self.labels_edit)
168        hlayout = QHBoxLayout()
169        hlayout.setContentsMargins(0, 0, 0, 0)
170        hlayout.setSpacing(1)
171        self.add_label_action = QAction("+", self,
172                        toolTip="Add a new label.",
173                        triggered=self.on_add_label,
174                        enabled=False,
175                        shortcut=QKeySequence(QKeySequence.New))
176
177        self.remove_label_action = QAction("-", self,
178                        toolTip="Remove selected label.",
179                        triggered=self.on_remove_label,
180                        enabled=False,
181                        shortcut=QKeySequence(QKeySequence.Delete))
[11430]182
[9325]183        button_size = OWGUI.toolButtonSizeHint()
[9326]184        button_size = QSize(button_size, button_size)
[11430]185
[9323]186        button = QToolButton(self)
[9325]187        button.setFixedSize(button_size)
[9323]188        button.setDefaultAction(self.add_label_action)
189        hlayout.addWidget(button)
[11430]190
[9323]191        button = QToolButton(self)
[9325]192        button.setFixedSize(button_size)
[9323]193        button.setDefaultAction(self.remove_label_action)
194        hlayout.addWidget(button)
195        hlayout.addStretch(10)
196        vlayout.addLayout(hlayout)
[11430]197
[9323]198        self.main_form.addRow("Labels", vlayout)
[11430]199
[9320]200    def set_data(self, var):
[10358]201        """Set the variable to edit.
[9323]202        """
[9320]203        self.clear()
204        self.var = var
[11430]205
[9320]206        if var is not None:
207            self.name_edit.setText(var.name)
208            self.labels_model.set_dict(dict(var.attributes))
[9323]209            self.add_label_action.setEnabled(True)
210        else:
211            self.add_label_action.setEnabled(False)
212            self.remove_label_action.setEnabled(False)
[11430]213
[9320]214    def get_data(self):
[10358]215        """Retrieve the modified variable.
[9323]216        """
[9320]217        name = str(self.name_edit.text())
218        labels = self.labels_model.get_dict()
[11430]219
220        # Is the variable actually changed.
[9320]221        if not self.is_same():
222            var = type(self.var)(name)
223            var.attributes.update(labels)
224            self.var = var
225        else:
226            var = self.var
[11430]227
[9320]228        return var
[11430]229
[9320]230    def is_same(self):
[11430]231        """Is the current model state the same as the input.
[9320]232        """
233        name = str(self.name_edit.text())
234        labels = self.labels_model.get_dict()
[11430]235
[9320]236        return self.var and name == self.var.name and labels == self.var.attributes
[11430]237
[9320]238    def clear(self):
[10358]239        """Clear the editor state.
[9323]240        """
[9320]241        self.var = None
242        self.name_edit.setText("")
243        self.labels_model.set_dict({})
[11430]244
[9320]245    def maybe_commit(self):
246        if not self.is_same():
247            self.commit()
[11430]248
[9320]249    def commit(self):
[10358]250        """Emit a ``variable_changed()`` signal.
[9323]251        """
[9320]252        self.emit(SIGNAL("variable_changed()"))
[11430]253
[9320]254    @QtCore.Slot()
255    def on_name_changed(self):
256        self.maybe_commit()
[11430]257
[9320]258    @QtCore.Slot()
[9323]259    def on_labels_changed(self, *args):
[9320]260        self.maybe_commit()
[11430]261
[9323]262    @QtCore.Slot()
263    def on_add_label(self):
264        self.labels_model.appendRow([PyStandardItem(""), PyStandardItem("")])
265        row = self.labels_model.rowCount() - 1
266        index = self.labels_model.index(row, 0)
267        self.labels_edit.edit(index)
[11430]268
[9323]269    @QtCore.Slot()
270    def on_remove_label(self):
271        rows = self.labels_edit.selectionModel().selectedRows()
272        if rows:
273            row = rows[0]
274            self.labels_model.removeRow(row.row())
[11430]275
[9323]276    @QtCore.Slot()
277    def on_label_selection_changed(self):
278        selected = self.labels_edit.selectionModel().selectedRows()
279        self.remove_label_action.setEnabled(bool(len(selected)))
[11430]280
281
[9320]282class DiscreteVariableEditor(VariableEditor):
[10358]283    """An editor widget for editing a discrete variable.
[11430]284
[9323]285    Extends the :class:`VariableEditor` to enable editing of
286    variables values.
[11430]287
[9323]288    """
[9320]289    def setup_gui(self):
290        layout = QVBoxLayout()
291        self.setLayout(layout)
[11430]292
[9320]293        self.main_form = QFormLayout()
294        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
295        layout.addLayout(self.main_form)
[11430]296
[9320]297        self._setup_gui_name()
298        self._setup_gui_values()
299        self._setup_gui_labels()
[11430]300
[9320]301    def _setup_gui_values(self):
302        self.values_edit = QListView()
[10726]303        self.values_edit.setEditTriggers(QTreeView.CurrentChanged)
[9320]304        self.values_model = PyListModel(flags=Qt.ItemIsSelectable | \
305                                        Qt.ItemIsEnabled | Qt.ItemIsEditable)
306        self.values_edit.setModel(self.values_model)
[11430]307
[9320]308        self.values_model.dataChanged.connect(self.on_values_changed)
309        self.main_form.addRow("Values", self.values_edit)
310
311    def set_data(self, var):
[10358]312        """Set the variable to edit
[9323]313        """
[9320]314        VariableEditor.set_data(self, var)
315        self.values_model.wrap([])
316        if var is not None:
317            for v in var.values:
318                self.values_model.append(v)
[11430]319
[9320]320    def get_data(self):
[10358]321        """Retrieve the modified variable
[9323]322        """
[9320]323        name = str(self.name_edit.text())
324        labels = self.labels_model.get_dict()
325        values = map(str, self.values_model)
[11430]326
[9320]327        if not self.is_same():
328            var = type(self.var)(name, values=values)
329            var.attributes.update(labels)
330            self.var = var
331        else:
332            var = self.var
[11430]333
[9320]334        return var
[11430]335
[9320]336    def is_same(self):
[11430]337        """Is the current model state the same as the input.
[9320]338        """
339        values = map(str, self.values_model)
340        return VariableEditor.is_same(self) and self.var.values == values
[11430]341
[9320]342    def clear(self):
[10358]343        """Clear the model state.
[9323]344        """
[9320]345        VariableEditor.clear(self)
346        self.values_model.wrap([])
[11430]347
[9320]348    @QtCore.Slot()
349    def on_values_changed(self):
350        self.maybe_commit()
[11430]351
352
[9320]353class ContinuousVariableEditor(VariableEditor):
[11430]354    # TODO: enable editing of number_of_decimals, scientific format ...
355    pass
[9320]356
357
358class OWEditDomain(OWWidget):
[11430]359    contextHandlers = {
360        "": DomainContextHandler(
361            "",
362            ["domain_change_hints", "selected_index"]
363        )
364    }
[9323]365    settingsList = ["auto_commit"]
[11430]366
[9320]367    def __init__(self, parent=None, signalManager=None, title="Edit Domain"):
368        OWWidget.__init__(self, parent, signalManager, title)
[11430]369
[9546]370        self.inputs = [("Data", Orange.data.Table, self.set_data)]
371        self.outputs = [("Data", Orange.data.Table)]
[11430]372
[9320]373        # Settings
[11430]374
[9323]375        # Domain change hints maps from input variables description to
376        # the modified variables description as returned by
377        # `variable_description` function
[9320]378        self.domain_change_hints = {}
379        self.selected_index = 0
380        self.auto_commit = False
381        self.changed_flag = False
[11430]382
[9320]383        self.loadSettings()
[11430]384
[9320]385        #####
386        # GUI
387        #####
[11430]388
[9323]389        # The list of domain's variables.
[9320]390        box = OWGUI.widgetBox(self.controlArea, "Domain Features")
391        self.domain_view = QListView()
392        self.domain_view.setSelectionMode(QListView.SingleSelection)
[11430]393
[9320]394        self.domain_model = VariableListModel()
[11430]395
[9320]396        self.domain_view.setModel(self.domain_model)
[11430]397
[9320]398        self.connect(self.domain_view.selectionModel(),
399                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
400                     self.on_selection_changed)
[11430]401
[9320]402        box.layout().addWidget(self.domain_view)
[11430]403
[9323]404        # A stack for variable editor widgets.
[9320]405        box = OWGUI.widgetBox(self.mainArea, "Edit Feature")
406        self.editor_stack = QStackedWidget()
407        box.layout().addWidget(self.editor_stack)
[11430]408
[9320]409        box = OWGUI.widgetBox(self.controlArea, "Reset")
[11430]410
[9320]411        OWGUI.button(box, self, "Reset selected",
412                     callback=self.reset_selected,
413                     tooltip="Reset changes made to the selected feature"
414                     )
[11430]415
[9320]416        OWGUI.button(box, self, "Reset all",
417                     callback=self.reset_all,
418                     tooltip="Reset all changes made to the domain"
419                     )
[11430]420
[9320]421        box = OWGUI.widgetBox(self.controlArea, "Commit")
[11430]422
[9320]423        b = OWGUI.button(box, self, "&Commit",
424                         callback=self.commit,
425                         tooltip="Commit the data with the changed domain",
426                         )
[11430]427
[9320]428        cb = OWGUI.checkBox(box, self, "auto_commit",
429                            label="Commit automatically",
430                            tooltip="Commit the changed domain on any change",
431                            callback=self.commit_if)
[11430]432
[9320]433        OWGUI.setStopper(self, b, cb, "changed_flag",
434                         callback=self.commit)
[11430]435
[9320]436        self._editor_cache = {}
[11430]437
[9320]438        self.resize(600, 500)
[11430]439
[9320]440    def clear(self):
[10358]441        """Clear the widget state.
[9323]442        """
[9320]443        self.data = None
444        self.domain_model[:] = []
445        self.domain_change_hints = {}
446        self.clear_editor()
[11430]447
[9320]448    def clear_editor(self):
[10358]449        """Clear the current editor widget
[9323]450        """
[9320]451        current = self.editor_stack.currentWidget()
452        if current:
453            QObject.disconnect(current, SIGNAL("variable_changed()"),
454                               self.on_variable_changed)
455            current.set_data(None)
[11430]456
[9320]457    def set_data(self, data=None):
458        self.closeContext("")
459        self.clear()
460        self.data = data
461        if data is not None:
462            input_domain = data.domain
[11432]463            all_vars = (list(input_domain.variables) +
464                        list(input_domain.class_vars) +
465                        input_domain.getmetas().values())
[11430]466
[9320]467            self.openContext("", data)
[11430]468
[9320]469            edited_vars = []
[11430]470
471            # Apply any saved transformations as listed in
[9323]472            # `domain_change_hints`
[11430]473
[9320]474            for var in all_vars:
475                desc = variable_description(var)
476                changed = self.domain_change_hints.get(desc, None)
477                if changed is not None:
[10358]478                    try:
479                        new = variable_from_description(changed)
480                    except ValueError, ex:
481#                        print ex
482                        new = None
[11430]483
[10358]484                    if new is not None:
485                        # Make sure orange's domain transformations will work.
486                        new.source_variable = var
487                        new.get_value_from = Orange.core.ClassifierFromVar(whichVar=var)
488                        var = new
[11430]489
[9320]490                edited_vars.append(var)
[11430]491
[9320]492            self.all_vars = all_vars
493            self.input_domain = input_domain
[11430]494
[9323]495            # Sets the model to display in the 'Domain Features' view
[9320]496            self.domain_model[:] = edited_vars
[11430]497
[9320]498            # Try to restore the variable selection
499            index = self.selected_index
500            if self.selected_index >= len(all_vars):
501                index = 0 if len(all_vars) else -1
502            if index >= 0:
503                self.select_variable(index)
[11430]504
[9321]505            self.changed_flag = True
506            self.commit_if()
507        else:
508            # To force send None on output
509            self.commit()
[11430]510
[9320]511    def on_selection_changed(self, *args):
[11430]512        """When selection in 'Domain Features' view changes.
[9323]513        """
[9320]514        i = self.selected_var_index()
515        if i is not None:
516            self.open_editor(i)
517            self.selected_index = i
[11430]518
[9320]519    def selected_var_index(self):
[11430]520        """Return the selected row in 'Domain Features' view or None
[9323]521        if no row is selected.
[11430]522
[9323]523        """
[9320]524        rows = self.domain_view.selectionModel().selectedRows()
525        if rows:
526            return rows[0].row()
527        else:
528            return None
[11430]529
[9320]530    def select_variable(self, index):
[10358]531        """Select the variable with ``index`` in the 'Domain Features'
[9323]532        view.
[11430]533
[9323]534        """
[9320]535        sel_model = self.domain_view.selectionModel()
536        sel_model.select(self.domain_model.index(index, 0),
537                         QItemSelectionModel.ClearAndSelect)
[11430]538
[9320]539    def open_editor(self, index):
[10358]540        """Open the editor for variable at ``index`` and move it
[9323]541        to the top if the stack.
[11430]542
[9323]543        """
[9320]544        # First remove and clear the current editor if any
545        self.clear_editor()
[11430]546
[9320]547        var = self.domain_model[index]
[11430]548
[9320]549        editor = self.editor_for_variable(var)
550        editor.set_data(var)
551        self.edited_variable_index = index
[11430]552
[9320]553        QObject.connect(editor, SIGNAL("variable_changed()"),
554                        self.on_variable_changed)
555        self.editor_stack.setCurrentWidget(editor)
[11430]556
[9320]557    def editor_for_variable(self, var):
[10358]558        """Return the editor for ``var``'s variable type.
[11430]559
[9323]560        The editors are cached and reused by type.
[11430]561
[9323]562        """
[9320]563        editor = None
564        if is_discrete(var):
565            editor = DiscreteVariableEditor
566        elif is_continuous(var):
567            editor = ContinuousVariableEditor
568        else:
569            editor = VariableEditor
[11430]570
[9320]571        if type(var) not in self._editor_cache:
572            editor = editor()
573            self._editor_cache[type(var)] = editor
574            self.editor_stack.addWidget(editor)
[11430]575
[9320]576        return self._editor_cache[type(var)]
[11430]577
[9320]578    def on_variable_changed(self):
[10358]579        """When the user edited the current variable in editor.
[9323]580        """
[9320]581        var = self.domain_model[self.edited_variable_index]
582        editor = self.editor_stack.currentWidget()
583        new_var = editor.get_data()
[11430]584
[9323]585        # Replace the variable in the 'Domain Features' view/model
[9320]586        self.domain_model[self.edited_variable_index] = new_var
587        old_var = self.all_vars[self.edited_variable_index]
[11430]588
[9323]589        # Store the transformation hint.
[9320]590        self.domain_change_hints[variable_description(old_var)] = \
591                    variable_description(new_var)
[9323]592
593        # Make orange's domain transformation work.
[9320]594        new_var.source_variable = old_var
595        new_var.get_value_from = Orange.core.ClassifierFromVar(whichVar=old_var)
[11430]596
[9320]597        self.commit_if()
[11430]598
[9320]599    def reset_all(self):
[10358]600        """Reset all variables to the input state.
[9323]601        """
[9320]602        self.domain_change_hints = {}
603        if self.data is not None:
604            # To invalidate stored hints
605            self.closeContext("")
606            self.openContext("", self.data)
607            self.domain_model[:] = self.all_vars
608            self.select_variable(self.selected_index)
[9324]609            self.commit_if()
[11430]610
[9320]611    def reset_selected(self):
[10358]612        """Reset the currently selected variable to its original
[9323]613        state.
[11430]614
[9323]615        """
[9320]616        if self.data is not None:
617            var = self.all_vars[self.selected_index]
[9324]618            desc = variable_description(var)
619            if desc in self.domain_change_hints:
620                del self.domain_change_hints[desc]
[11430]621
[9324]622            # To invalidate stored hints
623            self.closeContext("")
624            self.openContext("", self.data)
[11430]625
[9320]626            self.domain_model[self.selected_index] = var
627            self.editor_stack.currentWidget().set_data(var)
[9324]628            self.commit_if()
[11430]629
[9320]630    def commit_if(self):
631        if self.auto_commit:
632            self.commit()
633        else:
634            self.changed_flag = True
[11430]635
[9320]636    def commit(self):
[11430]637        """Commit the changed data to output.
[9323]638        """
[9320]639        new_data = None
640        if self.data is not None:
[11432]641            n_vars = len(self.input_domain.variables)
642            n_class_vars = len(self.input_domain.class_vars)
643            all_new_vars = list(self.domain_model)
644            variables = all_new_vars[: n_vars]
[9320]645            class_var = None
646            if self.input_domain.class_var:
647                class_var = variables[-1]
[11432]648                attributes = variables[:-1]
649            else:
650                attributes = variables
[11430]651
[11432]652            class_vars = all_new_vars[n_vars: n_vars + n_class_vars]
653            new_metas = all_new_vars[n_vars + n_class_vars:]
654            new_domain = Orange.data.Domain(attributes, class_var,
655                                            class_vars=class_vars)
[11430]656
[9320]657            # Assumes getmetas().items() order has not changed.
[9323]658            # TODO: store metaids in set_data method
[11430]659            for (mid, _), new in zip(self.input_domain.getmetas().items(),
[11432]660                                     new_metas):
[9320]661                new_domain.addmeta(mid, new)
[11430]662
[9320]663            new_data = Orange.data.Table(new_domain, self.data)
[11430]664
[9546]665        self.send("Data", new_data)
[9320]666        self.changed_flag = False
[11430]667
668
[9320]669def main():
670    import sys
671    app = QApplication(sys.argv)
672    w = OWEditDomain()
673    data = Orange.data.Table("iris")
674#    data = Orange.data.Table("rep:GDS636.tab")
675    w.set_data(data)
676    w.show()
677    rval = app.exec_()
678    w.set_data(None)
679    w.saveSettings()
680    return rval
[11430]681
[9320]682if __name__ == "__main__":
683    import sys
684    sys.exit(main())
Note: See TracBrowser for help on using the repository browser.