Skip to content

Commit

Permalink
ScoreTable: Add indicator to select scores
Browse files Browse the repository at this point in the history
  • Loading branch information
janezd committed Jan 7, 2023
1 parent 6d5f0ac commit e8551d1
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 59 deletions.
8 changes: 5 additions & 3 deletions Orange/widgets/evaluate/tests/test_owtestandscore.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ def __call__(self, data):
header = view.horizontalHeader()
p = header.rect().center()
# second visible header section (after 'Model')
_, idx, *_ = (i for i in range(header.count())
_, _, idx, *_ = (i for i in range(header.count())
if not header.isSectionHidden(i))
p.setX(header.sectionPosition(idx) + 5)
QTest.mouseClick(header.viewport(), Qt.LeftButton, pos=p)
Expand Down Expand Up @@ -723,11 +723,13 @@ def test_copy_to_clipboard(self):
selection_model = view.selectionModel()
selection_model.select(model.index(0, 0),
selection_model.Select | selection_model.Rows)

self.widget.copy_to_clipboard()
clipboard_text = QApplication.clipboard().text()
# Tests appear to register additional scorers, so we clip the list
# to what we know is there and visible
clipboard_text = "\t".join(clipboard_text.split("\t")[:6]).strip()
view_text = "\t".join([str(model.data(model.index(0, i)))
for i in (0, 3, 4, 5, 6, 7)]) + "\r\n"
for i in (0, 3, 4, 5, 6, 7)]).strip()
self.assertEqual(clipboard_text, view_text)

def test_multi_target_input(self):
Expand Down
20 changes: 9 additions & 11 deletions Orange/widgets/evaluate/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ class NewScore(Score):
Specificity=False, NewScore=True))
self.score_table = ScoreTable(None)
self.score_table.update_header([F1, CA, AUC, Specificity, NewScore])
self.score_table._update_shown_columns()

def tearDown(self):
ScoreTable.show_score_hints = self.orig_hints
Expand All @@ -75,20 +74,19 @@ def addAction(menu, a):
def execmenu(*_):
# pylint: disable=unsubscriptable-object,unsupported-assignment-operation
scorers = [F1, CA, AUC, Specificity, self.NewScore]
self.assertEqual(list(actions)[2:], ['F1',
self.assertEqual(list(actions)[3:], ['F1',
'Classification accuracy (CA)',
'Area under ROC curve (AUC)',
'Specificity (Spec)',
'new score'])
header = self.score_table.view.horizontalHeader()
for i, action, scorer in zip(count(), actions.values(), scorers):
if i >= 2:
self.assertEqual(action.isChecked(),
hints[scorer.__name__],
msg=f"error in section {scorer.name}")
self.assertEqual(header.isSectionHidden(i),
hints[scorer.__name__],
msg=f"error in section {scorer.name}")
for i, action, scorer in zip(count(), list(actions.values())[3:], scorers):
self.assertEqual(action.isChecked(),
hints[scorer.__name__],
msg=f"error in section {scorer.name}")
self.assertEqual(header.isSectionHidden(3 + i),
not hints[scorer.__name__],
msg=f"error in section {scorer.name}")
actions["Classification accuracy (CA)"].triggered.emit(True)
hints["CA"] = True
for k, v in hints.items():
Expand All @@ -107,7 +105,7 @@ def execmenu(*_):
# `menuexec` finishes.
with patch("AnyQt.QtWidgets.QMenu.addAction", addAction), \
patch("AnyQt.QtWidgets.QMenu.exec", execmenu):
self.score_table.show_column_chooser(QPoint(0, 0))
self.score_table.view.horizontalHeader().show_column_chooser(QPoint(0, 0))

def test_sorting(self):
def order(n=5):
Expand Down
150 changes: 105 additions & 45 deletions Orange/widgets/evaluate/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from typing import Union, Dict, List

import numpy as np
from sklearn.exceptions import UndefinedMetricWarning

from AnyQt.QtWidgets import QHeaderView, QStyledItemDelegate, QMenu, \
QApplication
from AnyQt.QtGui import QStandardItemModel, QStandardItem, QClipboard
QApplication, QToolButton
from AnyQt.QtGui import QStandardItemModel, QStandardItem, QClipboard, QColor
from AnyQt.QtCore import Qt, QSize, QObject, pyqtSignal as Signal, \
QSortFilterProxyModel
from sklearn.exceptions import UndefinedMetricWarning

from orangewidget.gui import OrangeUserRole

from Orange.data import Domain, Variable
from Orange.evaluation import scoring
Expand Down Expand Up @@ -128,7 +130,84 @@ def is_bad(x):
return left < right


DEFAULT_HINTS = {"Model_": True, "Train": False, "Test": False}
DEFAULT_HINTS = {"Model_": True, "Train_": False, "Test_": False}


class PersistentMenu(QMenu):
def mouseReleaseEvent(self, e):
action = self.activeAction()
if action:
action.setEnabled(False)
super().mouseReleaseEvent(e)
action.setEnabled(True)
action.trigger()
else:
super().mouseReleaseEvent(e)


class SelectableColumnsHeader(QHeaderView):
SelectMenuRole = next(OrangeUserRole)
ShownHintRole = next(OrangeUserRole)
sectionVisibleChanged = Signal(int, bool)

def __init__(self, shown_columns_hints, *args, **kwargs):
super().__init__(Qt.Horizontal, *args, **kwargs)
self.show_column_hints = shown_columns_hints
self.button = QToolButton(self)
self.button.setArrowType(Qt.DownArrow)
self.button.setFixedSize(24, 12)
col = self.button.palette().color(self.button.backgroundRole())
self.button.setStyleSheet(
f"border: none; background-color: {col.name(QColor.NameFormat.HexRgb)}")
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_column_chooser)
self.button.clicked.connect(self._on_button_clicked)

def showEvent(self, e):
self._set_pos()
self.button.show()
super().showEvent(e)

def resizeEvent(self, e):
self._set_pos()
super().resizeEvent(e)

def _set_pos(self):
w, h = self.button.width(), self.button.height()
vw, vh = self.viewport().width(), self.viewport().height()
self.button.setGeometry(vw - w, (vh - h) // 2, w, h)

def __data(self, section, role):
return self.model().headerData(section, Qt.Horizontal, role)

def show_column_chooser(self, pos):
# pylint: disable=unsubscriptable-object, unsupported-assignment-operation
menu = PersistentMenu()
for section in range(self.count()):
name, enabled = self.__data(section, self.SelectMenuRole)
hint_id = self.__data(section, self.ShownHintRole)
action = menu.addAction(name)
action.setDisabled(not enabled)
action.setCheckable(True)
action.setChecked(self.show_column_hints[hint_id])

@action.triggered.connect # pylint: disable=cell-var-from-loop
def update(checked, q=hint_id, section=section):
self.show_column_hints[q] = checked
self.setSectionHidden(section, not checked)
self.sectionVisibleChanged.emit(section, checked)
self.resizeSections(self.ResizeToContents)

pos.setY(self.viewport().height())
menu.exec(self.mapToGlobal(pos))

def _on_button_clicked(self):
self.show_column_chooser(self.button.pos())

def update_shown_columns(self):
for section in range(self.count()):
hint_id = self.__data(section, self.ShownHintRole)
self.setSectionHidden(section, not self.show_column_hints[hint_id])


class ScoreTable(OWComponent, QObject):
Expand All @@ -138,6 +217,7 @@ class ScoreTable(OWComponent, QObject):
# backwards compatibility
@property
def shown_scores(self):
# pylint: disable=unsubscriptable-object
column_names = {
self.model.horizontalHeaderItem(col).data(Qt.DisplayRole)
for col in range(1, self.model.columnCount())}
Expand Down Expand Up @@ -166,65 +246,45 @@ def __init__(self, master):
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setDefaultAlignment(Qt.AlignCenter)
header.setStretchLastSection(False)
header.setContextMenuPolicy(Qt.CustomContextMenu)
header.customContextMenuRequested.connect(self.show_column_chooser)

for score in Score.registry.values():
self.show_score_hints.setdefault(score.__name__, score.default_visible)

self.model = QStandardItemModel(master)
self.model.setHorizontalHeaderLabels(["Method"])
header = SelectableColumnsHeader(self.show_score_hints)
header.setSectionsClickable(True)
self.view.setHorizontalHeader(header)
self.sorted_model = ScoreModel()
self.sorted_model.setSourceModel(self.model)
self.view.setModel(self.sorted_model)
self.view.setItemDelegate(self.ItemDelegate())

def show_column_chooser(self, pos):
menu = QMenu()
header = self.view.horizontalHeader()
for col in range(1, self.model.columnCount()):
item = self.model.horizontalHeaderItem(col)
qualname = item.data(Qt.UserRole)
if col < 3:
option = item.data(Qt.DisplayRole)
else:
score = Score.registry[qualname]
option = score.long_name
if score.name != score.long_name:
option += f" ({score.name})"
action = menu.addAction(option)
action.setCheckable(True)
action.setChecked(self.show_score_hints[qualname])

@action.triggered.connect
def update(checked, q=qualname):
self.show_score_hints[q] = checked
self._update_shown_columns()

menu.exec(header.mapToGlobal(pos))

def _update_shown_columns(self):
self.view.resizeColumnsToContents()
header = self.view.horizontalHeader()
for section in range(1, header.count()):
qualname = self.model.horizontalHeaderItem(section).data(Qt.UserRole)
header.setSectionHidden(section, not self.show_score_hints[qualname])
self.shownScoresChanged.emit()
header.sectionVisibleChanged.connect(self.shownScoresChanged.emit)
self.sorted_model.dataChanged.connect(self.view.resizeColumnsToContents)

def update_header(self, scorers: List[Score]):
self.model.setColumnCount(3 + len(scorers))
for i, name, id_ in ((0, "Model", "Model_"),
(1, "Train time [s]", "Train"),
(2, "Test time [s]", "Test")):
SelectMenuRole = SelectableColumnsHeader.SelectMenuRole
ShownHintRole = SelectableColumnsHeader.ShownHintRole
for i, name, long_name, id_, in ((0, "Model", "Model", "Model_"),
(1, "Train", "Train time [s]", "Train_"),
(2, "Test", "Test time [s]", "Test_")):
item = QStandardItem(name)
item.setData(id_, Qt.UserRole)
item.setData((long_name, i != 0), SelectMenuRole)
item.setData(id_, ShownHintRole)
item.setToolTip(long_name)
self.model.setHorizontalHeaderItem(i, item)
for col, score in enumerate(scorers, start=3):
item = QStandardItem(score.name)
item.setData(score.__name__, Qt.UserRole)
name = score.long_name
if name != score.name:
name += f" ({score.name})"
item.setData((name, True), SelectMenuRole)
item.setData(score.__name__, ShownHintRole)
item.setToolTip(score.long_name)
self.model.setHorizontalHeaderItem(col, item)
self._update_shown_columns()

self.view.horizontalHeader().update_shown_columns()
self.view.resizeColumnsToContents()

def copy_selection_to_clipboard(self):
mime = table_selection_to_mime_data(self.view)
Expand Down

0 comments on commit e8551d1

Please sign in to comment.