source: orange/Orange/OrangeWidgets/Data/OWMultiMergeData.py @ 10692:7c3700e1881e

Revision 10692:7c3700e1881e, 23.0 KB checked in by Peter Husen <peter@…>, 2 years ago (diff)

Added multi-key merge widget

Line 
1"""
2<name>Multi-key Merge Data</name>
3<description>Merge datasets based on values of selected tuples of attributes.</description>
4<icon>icons/MergeData.png</icon>
5<priority>100</priority>
6<contact>Peter Husen (phusen@bmb.sdu.dk)</contact>
7"""
8
9from OWWidget import *
10from OWItemModels import PyListModel, VariableListModel
11
12#import OWGUI
13import Orange
14
15
16def slices(indices):
17    """ Group the given integer indices into slices
18    """
19    indices = list(sorted(indices))
20    if indices:
21        first = last = indices[0]
22        for i in indices[1:]:
23            if i == last + 1:
24                last = i
25            else:
26                yield first, last + 1
27                first = last = i
28        yield first, last + 1
29
30
31def delslice(model, start, end):
32    """ Delete the start, end slice (rows) from the model.
33    """
34    if isinstance(model, PyListModel):
35        model.__delslice__(start, end)
36    elif isinstance(model, QAbstractItemModel):
37        model.removeRows(start, end-start)
38    else:
39        raise TypeError(type(model))
40
41
42class VariablesListItemModel(VariableListModel):
43    """ An Qt item model for for list of orange.Variable objects.
44    Supports drag operations
45    """
46       
47    def flags(self, index):
48        flags = VariableListModel.flags(self, index)
49        if index.isValid():
50            flags |= Qt.ItemIsDragEnabled
51        else:
52            flags |= Qt.ItemIsDropEnabled
53        return flags
54   
55    ###########
56    # Drag/Drop
57    ###########
58   
59    MIME_TYPE = "application/x-Orange-VariableListModelData"
60   
61    def supportedDropActions(self):
62        return Qt.MoveAction
63   
64    def supportedDragActions(self):
65        return Qt.MoveAction
66   
67    def mimeTypes(self):
68        return [self.MIME_TYPE]
69   
70    def mimeData(self, indexlist):
71        descriptors = []
72        vars = []
73        item_data = []
74        for index in indexlist:
75            var = self[index.row()]
76            descriptors.append((var.name, var.varType))
77            vars.append(var)
78            item_data.append(self.itemData(index))
79       
80        mime = QMimeData()
81        mime.setData(self.MIME_TYPE, QByteArray(str(descriptors)))
82        mime._vars = vars
83        mime._item_data = item_data
84        return mime
85   
86    def dropMimeData(self, mime, action, row, column, parent):
87        if action == Qt.IgnoreAction:
88            return True
89   
90        vars, item_data = self.items_from_mime_data(mime)
91        if vars is None:
92            return False
93       
94        if row == -1:
95            row = len(self)
96           
97        self.__setslice__(row, row, vars)
98       
99        for i, data in enumerate(item_data):
100            self.setItemData(self.index(row + i), data)
101           
102        return True
103   
104    def items_from_mime_data(self, mime):
105        if not mime.hasFormat(self.MIME_TYPE):
106            return None, None
107       
108        if hasattr(mime, "_vars"):
109            vars = mime._vars
110            item_data = mime._item_data
111            return vars, item_data
112        else:
113            #TODO: get vars from orange.Variable.getExisting
114            return None, None
115
116
117class VariablesListItemView(QListView):
118    """ A Simple QListView subclass initialized for displaying
119    variables.
120    """
121    def __init__(self, parent=None):
122        QListView.__init__(self, parent)
123        self.setSelectionMode(QListView.ExtendedSelection)
124        self.setAcceptDrops(True)
125        self.setDragEnabled(True)
126        self.setDropIndicatorShown(True)
127        self.setDragDropMode(QListView.DragDrop)
128        if hasattr(self, "setDefaultDropAction"):
129            # For compatibility with Qt version < 4.6
130            self.setDefaultDropAction(Qt.MoveAction)
131        self.setDragDropOverwriteMode(False)
132        self.viewport().setAcceptDrops(True)
133   
134    def startDrag(self, supported_actions):
135        indices = self.selectionModel().selectedIndexes()
136        indices = [i for i in indices if i.flags() & Qt.ItemIsDragEnabled]
137        if indices:
138            data = self.model().mimeData(indices)
139            if not data:
140                return
141            # rect = QRect()
142           
143            drag = QDrag(self)
144            drag.setMimeData(data)
145           
146            default_action = Qt.IgnoreAction
147            if hasattr(self, "defaultDropAction") and \
148                    self.defaultDropAction() != Qt.IgnoreAction and \
149                    supported_actions & self.defaultDropAction():
150                default_action = self.defaultDropAction()
151            elif supported_actions & Qt.CopyAction and dragDropMode() != QListView.InternalMove:
152                default_action = Qt.CopyAction
153           
154            res = drag.exec_(supported_actions, default_action)
155               
156            if res == Qt.MoveAction:
157                selected = self.selectionModel().selectedIndexes()
158                rows = map(QModelIndex.row, selected)
159               
160                for s1, s2 in reversed(list(slices(rows))):
161                    delslice(self.model(), s1, s2)
162   
163    def render_to_pixmap(self, indices):
164        pass
165
166
167from functools import partial
168class OWMultiMergeData(OWWidget):
169    contextHandlers = { "A": DomainContextHandler("A", [ContextField("domainA_role_hints")]),
170                        "B": DomainContextHandler("B", [ContextField("domainB_role_hints")]) }
171
172    def __init__(self, parent = None, signalManager = None, name = "Merge data"):
173        OWWidget.__init__(self, parent, signalManager, name, wantMainArea = 0)  #initialize base class
174
175        # set channels
176        self.inputs = [("Data A", ExampleTable, self.set_dataA),
177                       ("Data B", ExampleTable, self.set_dataB)]
178       
179        self.outputs = [("Merged Data A+B", ExampleTable),
180                        ("Merged Data B+A", ExampleTable)]
181
182        self.domainA_role_hints = {}
183        self.domainB_role_hints = {}
184       
185        # data
186        self.dataA = None
187        self.dataB = None
188
189        # load settings
190        self.loadSettings()
191
192        # ####
193        # GUI
194        # ####
195       
196        import sip
197        sip.delete(self.controlArea.layout())
198       
199        layout = QGridLayout()
200        layout.setMargin(0)
201
202        # Available A attributes
203        box = OWGUI.widgetBox(self.controlArea, "Available A attributes", addToLayout=False)
204        self.available_attrsA = VariablesListItemModel()
205        self.available_attrsA_view = VariablesListItemView()
206        self.available_attrsA_view.setModel(self.available_attrsA)
207       
208        self.connect(self.available_attrsA_view.selectionModel(),
209                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
210                     partial(self.update_interface_state,
211                             self.available_attrsA_view))
212       
213        box.layout().addWidget(self.available_attrsA_view)
214        layout.addWidget(box, 0, 0, 2, 1)
215
216
217        # Used A Attributes
218        box = OWGUI.widgetBox(self.controlArea, "Used A attributes", addToLayout=False)
219        self.used_attrsA = VariablesListItemModel()
220        self.used_attrsA_view = VariablesListItemView()
221        self.used_attrsA_view.setModel(self.used_attrsA)
222        self.connect(self.used_attrsA_view.selectionModel(),
223                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
224                     partial(self.update_interface_state,
225                             self.used_attrsA_view))
226       
227        box.layout().addWidget(self.used_attrsA_view)
228        layout.addWidget(box, 0, 2, 1, 1)
229
230
231        # Data A info
232        box = OWGUI.widgetBox(self, 'Data A', orientation = "vertical", addToLayout=0)
233        self.lblDataAExamples = OWGUI.widgetLabel(box, "num examples")
234        self.lblDataAAttributes = OWGUI.widgetLabel(box, "num attributes")
235        layout.addWidget(box, 1, 1, 1, 2)
236
237
238        # Available B attributes
239        box = OWGUI.widgetBox(self.controlArea, "Available B attributes", addToLayout=False)
240        self.available_attrsB = VariablesListItemModel()
241        self.available_attrsB_view = VariablesListItemView()
242        self.available_attrsB_view.setModel(self.available_attrsB)
243       
244        self.connect(self.available_attrsB_view.selectionModel(),
245                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
246                     partial(self.update_interface_state,
247                             self.available_attrsB_view))
248       
249        box.layout().addWidget(self.available_attrsB_view)
250        layout.addWidget(box, 2, 0, 2, 1)
251
252
253        # Used B Attributes
254        box = OWGUI.widgetBox(self.controlArea, "Used B attributes", addToLayout=False)
255        self.used_attrsB = VariablesListItemModel()
256        self.used_attrsB_view = VariablesListItemView()
257        self.used_attrsB_view.setModel(self.used_attrsB)
258        self.connect(self.used_attrsB_view.selectionModel(),
259                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
260                     partial(self.update_interface_state,
261                             self.used_attrsB_view))
262
263
264        # Data B info
265        box.layout().addWidget(self.used_attrsB_view)
266        layout.addWidget(box, 2, 2, 1, 1)
267
268        box = OWGUI.widgetBox(self, 'Data B', orientation = "vertical", addToLayout=0)
269        self.lblDataBExamples = OWGUI.widgetLabel(box, "num examples")
270        self.lblDataBAttributes = OWGUI.widgetLabel(box, "num attributes")
271        layout.addWidget(box, 3, 1, 1, 2)
272
273
274        # A buttons
275        bbox = OWGUI.widgetBox(self.controlArea, addToLayout=False, margin=0)
276        layout.addWidget(bbox, 0, 1, 1, 1)
277       
278        self.up_attrA_button = OWGUI.button(bbox, self, "Up", 
279                    callback = partial(self.move_up, self.used_attrsA_view))
280        self.move_attrA_button = OWGUI.button(bbox, self, ">",
281                    callback = partial(self.move_selected, self.used_attrsA_view))
282        self.down_attrA_button = OWGUI.button(bbox, self, "Down",
283                    callback = partial(self.move_down, self.used_attrsA_view))
284
285
286        # B buttons
287        bbox = OWGUI.widgetBox(self.controlArea, addToLayout=False, margin=0)
288        layout.addWidget(bbox, 2, 1, 1, 1)
289
290        self.up_attrB_button = OWGUI.button(bbox, self, "Up",
291                    callback = partial(self.move_up, self.used_attrsB_view))
292        self.move_attrB_button = OWGUI.button(bbox, self, ">",
293                    callback = partial(self.move_selected, self.used_attrsB_view))
294        self.down_attrB_button = OWGUI.button(bbox, self, "Down",
295                    callback = partial(self.move_down, self.used_attrsB_view))
296
297
298        # Apply / reset
299        bbox = OWGUI.widgetBox(self.controlArea, orientation="horizontal", addToLayout=False, margin=0)
300        applyButton = OWGUI.button(bbox, self, "Apply", callback=self.commit)
301        resetButton = OWGUI.button(bbox, self, "Reset", callback=self.reset)
302       
303        layout.addWidget(bbox, 4, 0, 1, 3)
304       
305        layout.setHorizontalSpacing(0)
306        self.controlArea.setLayout(layout)
307       
308        self.data = None
309        self.output_report = None
310
311        self.resize(500, 600)
312       
313        # For automatic widget testing using
314        self._guiElements.extend( \
315                  [(QListView, self.available_attrsA_view),
316                   (QListView, self.used_attrsA_view),
317                   (QListView, self.available_attrsB_view),
318                   (QListView, self.used_attrsB_view),
319                  ])
320
321
322    ############################################################################################################################################################
323    ## Data input and output management
324    ############################################################################################################################################################
325   
326   
327    def set_dataA(self, data=None):
328        self.update_domainA_role_hints()
329        self.closeContext("A")
330        self.dataA = data
331        if data is not None:
332            self.openContext("A", data)
333            all_vars = data.domain.variables + data.domain.getmetas().values()
334           
335            var_sig = lambda attr: (attr.name, attr.varType)
336           
337            domain_hints = dict([(var_sig(attr), ("available", i)) \
338                            for i, attr in enumerate(data.domain.attributes)])
339           
340            domain_hints.update(dict([(var_sig(attr), ("available", i)) \
341                for i, attr in enumerate(data.domain.getmetas().values())]))
342           
343            if data.domain.class_var:
344                domain_hints[var_sig(data.domain.class_var)] = ("available", 0)
345                   
346            domain_hints.update(self.domainA_role_hints) # update the hints from context settings
347           
348            attrs_for_role = lambda role: [(domain_hints[var_sig(attr)][1], attr) \
349                    for attr in all_vars if domain_hints[var_sig(attr)][0] == role]
350           
351            available = [attr for place, attr in sorted(attrs_for_role("available"))]
352            used = [attr for place, attr in sorted(attrs_for_role("used"))]
353           
354            self.available_attrsA[:] = available
355            self.used_attrsA[:] = used
356        else:
357            self.available_attrsA[:] = []
358            self.used_attrsA[:] = []
359       
360        self.updateInfoA()
361        self.commit()
362
363
364    def set_dataB(self, data=None):
365        self.update_domainB_role_hints()
366        self.closeContext("B")
367        self.dataB = data
368        if data is not None:
369            self.openContext("B", data)
370            all_vars = data.domain.variables + data.domain.getmetas().values()
371           
372            var_sig = lambda attr: (attr.name, attr.varType)
373           
374            domain_hints = dict([(var_sig(attr), ("available", i)) \
375                            for i, attr in enumerate(data.domain.attributes)])
376           
377            domain_hints.update(dict([(var_sig(attr), ("available", i)) \
378                for i, attr in enumerate(data.domain.getmetas().values())]))
379           
380            if data.domain.class_var:
381                domain_hints[var_sig(data.domain.class_var)] = ("available", 0)
382                   
383            domain_hints.update(self.domainB_role_hints) # update the hints from context settings
384           
385            attrs_for_role = lambda role: [(domain_hints[var_sig(attr)][1], attr) \
386                    for attr in all_vars if domain_hints[var_sig(attr)][0] == role]
387           
388            available = [attr for place, attr in sorted(attrs_for_role("available"))]
389            used = [attr for place, attr in sorted(attrs_for_role("used"))]
390           
391            self.available_attrsB[:] = available
392            self.used_attrsB[:] = used
393        else:
394            self.available_attrsB[:] = []
395            self.used_attrsB[:] = []
396
397        self.updateInfoB()
398        self.commit()
399
400
401    def updateInfoA(self):
402        """Updates data A info box.
403        """
404        if self.dataA:
405            self.lblDataAExamples.setText("%s example%s" % self._sp(self.dataA))
406            self.lblDataAAttributes.setText("%s attribute%s" % self._sp(self.available_attrsA[:] + self.used_attrsA[:]))
407        else:
408            self.lblDataAExamples.setText("No data on input A.")
409            self.lblDataAAttributes.setText("")
410
411
412    def updateInfoB(self):
413        """Updates data B info box.
414        """
415        if self.dataB:
416            self.lblDataBExamples.setText("%s example%s" % self._sp(self.dataB))
417            self.lblDataBAttributes.setText("%s attribute%s" % self._sp(self.available_attrsB[:] + self.used_attrsB[:]))
418        else:
419            self.lblDataBExamples.setText("No data on input B.")
420            self.lblDataBAttributes.setText("")
421
422
423    def update_domainA_role_hints(self):
424        """ Update the domain hints to be stored in the widgets settings.
425        """
426        hints_from_model = lambda role, model: \
427                [((attr.name, attr.varType), (role, i)) \
428                 for i, attr in enumerate(model)]
429       
430        hints = dict(hints_from_model("available", self.available_attrsA))
431        hints.update(hints_from_model("used", self.used_attrsA))
432        self.domainA_role_hints = hints
433
434    def update_domainB_role_hints(self):
435        """ Update the domain hints to be stored in the widgets settings.
436        """
437        hints_from_model = lambda role, model: \
438                [((attr.name, attr.varType), (role, i)) \
439                 for i, attr in enumerate(model)]
440       
441        hints = dict(hints_from_model("available", self.available_attrsB))
442        hints.update(hints_from_model("used", self.used_attrsB))
443        self.domainB_role_hints = hints
444
445    def move_rows(self, view, rows, offset):
446        model = view.model()
447        newrows = [min(max(0, row + offset), len(model) - 1) for row in rows]
448       
449        for row, newrow in sorted(zip(rows, newrows), reverse=offset > 0):
450            model[row], model[newrow] = model[newrow], model[row]
451           
452        selection = QItemSelection()
453        for nrow in newrows:
454            index = model.index(nrow, 0)
455            selection.select(index, index)
456        view.selectionModel().select(selection, QItemSelectionModel.ClearAndSelect)
457
458    def move_up(self, view):
459        selected = self.selected_rows(view)
460        self.move_rows(view, selected, -1)
461   
462    def move_down(self, view):
463        selected = self.selected_rows(view)
464        self.move_rows(view, selected, 1)
465       
466    def selected_rows(self, view):
467        """ Return the selected rows in the view.
468        """
469        rows = view.selectionModel().selectedRows()
470        model = view.model()
471        return [r.row() for r in rows]
472
473    def move_selected(self, view):
474        fromto = {
475            self.available_attrsA_view: self.used_attrsA_view,
476            self.available_attrsB_view: self.used_attrsB_view,
477            self.used_attrsA_view: self.available_attrsA_view,
478            self.used_attrsB_view: self.available_attrsB_view
479        }
480        if self.selected_rows(view):
481            self.move_selected_from_to(view, fromto[view])
482        else:
483            self.move_selected_from_to(fromto[view], view)
484   
485    def move_selected_from_to(self, src, dst):
486        self.move_from_to(src, dst, self.selected_rows(src))
487       
488    def move_from_to(self, src, dst, rows):
489        src_model = src.model()
490        attrs = [src_model[r] for r in rows]
491
492        for s1, s2 in reversed(list(slices(rows))):
493            del src_model[s1:s2]
494
495        dst_model = dst.model()
496        dst_model.extend(attrs)
497
498
499    def reset(self):
500        if self.dataA is not None:
501            meta_attrsA = self.dataA.domain.getmetas().values()
502            self.available_attrsA[:] = self.dataA.domain.variables + meta_attrsA
503            self.used_attrsA[:] = []
504            self.update_domainA_role_hints()
505        if self.dataB is not None:
506            meta_attrsB = self.dataB.domain.getmetas().values()
507            self.available_attrsB[:] = self.dataB.domain.variables + meta_attrsB
508            self.used_attrsB[:] = []
509            self.update_domainB_role_hints()
510
511
512    def update_interface_state(self, focus=None, selected=None, deselected=None):
513        for view in [self.available_attrsA_view, self.used_attrsA_view,
514                     self.available_attrsB_view, self.used_attrsB_view]:
515            if view is not focus and not view.hasFocus():
516                view.selectionModel().clear()
517               
518        availableA_selected = bool(self.available_attrsA_view.selectionModel().selectedRows())
519        availableB_selected = bool(self.available_attrsB_view.selectionModel().selectedRows())
520                              #bool(self.selected_rows(self.available_attrs_view))
521                                 
522        move_attrA_enabled = bool(availableA_selected or \
523                                  self.used_attrsA_view.selectionModel().selectedRows())
524        move_attrB_enabled = bool(availableB_selected or \
525                                  self.used_attrsB_view.selectionModel().selectedRows())
526
527        self.move_attrA_button.setEnabled(move_attrA_enabled)
528        self.move_attrB_button.setEnabled(move_attrB_enabled)
529        if move_attrA_enabled:
530            self.move_attrA_button.setText(">" if availableA_selected else "<")
531        if move_attrB_enabled:
532            self.move_attrB_button.setText(">" if availableB_selected else "<")
533
534    def commit(self):
535        if self.dataA: self.update_domainA_role_hints()
536        if self.dataB: self.update_domainB_role_hints()
537        self.error(0)
538        if self.dataA and self.dataB and list(self.used_attrsA) and list(self.used_attrsB):
539            try:
540                self.send("Merged Data A+B", self.merge(self.dataA, self.dataB, self.used_attrsA, self.used_attrsB))
541                self.send("Merged Data B+A", self.merge(self.dataB, self.dataA, self.used_attrsB, self.used_attrsA))
542            except Orange.core.KernelException, ex:
543                self.error(0, "Cannot merge the two tables (%r)" % str(ex))
544        else:
545            self.send("Merged Data A+B", None)
546            self.send("Merged Data B+A", None)
547
548    ############################################################################################################################################################
549    ## Utility functions
550    ############################################################################################################################################################
551
552    def _sp(self, l, capitalize=True):
553        """Input: list; returns tuple (str(len(l)), "s"/"")
554        """
555        n = len(l)
556        if n == 0:
557            if capitalize:
558                return "No", "s"
559            else:
560                return "no", "s"
561        elif n == 1:
562            return str(n), ''
563        else:
564            return str(n), 's'
565
566    def merge(self, dataA, dataB, varsA, varsB):
567        """ Merge two tables
568        """
569       
570        vkey = lambda row, vs : tuple( row[v].native() for v in vs )
571       
572        val2idx = dict([( vkey(e,varsB) , i) for i, e in reversed(list(enumerate(dataB)))])
573       
574        #for key in ["?", "~", ""]:
575        #    if key in val2idx:
576        #        val2idx.pop(key)
577
578        metasA = dataA.domain.getmetas().items()
579        metasB = dataB.domain.getmetas().items()
580       
581        includedAttsB = [attrB for attrB in dataB.domain if attrB not in dataA.domain]
582        includedMetaB = [(mid, meta) for mid, meta in metasB if (mid, meta) not in metasA]
583        includedClassVarB = dataB.domain.classVar and dataB.domain.classVar not in dataA.domain
584       
585        reducedDomainB = Orange.data.Domain(includedAttsB, includedClassVarB)
586        reducedDomainB.addmetas(dict(includedMetaB))
587       
588       
589        mergingB = Orange.data.Table(reducedDomainB)
590       
591        for ex in dataA:
592            ind = val2idx.get( vkey(ex,varsA), None)
593            if ind is not None:
594                mergingB.append(Orange.data.Instance(reducedDomainB, dataB[ind]))
595               
596            else:
597                mergingB.append(Orange.data.Instance(reducedDomainB, ["?"] * len(reducedDomainB)))
598               
599        return Orange.data.Table([dataA, mergingB])
600   
601if __name__=="__main__":
602    import sys
603    a=QApplication(sys.argv)
604    ow=OWMultiMergeData()
605    ow.show()
606    data = Orange.data.Table("iris.tab")
607    data2 = Orange.data.Table("iris.tab")
608    ow.set_dataA(data)
609    ow.set_dataB(data2)
610    a.exec_()
611
612
Note: See TracBrowser for help on using the repository browser.