From 4e862e5b4c3d2cf8e15b4c444d2b0df92d716e5c Mon Sep 17 00:00:00 2001 From: janezd Date: Fri, 25 Oct 2024 21:14:35 +0200 Subject: [PATCH] Parameter Fitter: GUI changes, annotations --- Orange/widgets/evaluate/owparameterfitter.py | 283 ++++++++++++------ .../evaluate/tests/test_owparameterfitter.py | 108 ++++++- 2 files changed, 297 insertions(+), 94 deletions(-) diff --git a/Orange/widgets/evaluate/owparameterfitter.py b/Orange/widgets/evaluate/owparameterfitter.py index 842a6d9e36..74cd65e830 100644 --- a/Orange/widgets/evaluate/owparameterfitter.py +++ b/Orange/widgets/evaluate/owparameterfitter.py @@ -1,10 +1,11 @@ -from typing import Optional, Callable, Iterable, Sized +from typing import Optional, Callable, Collection, Sequence, Any import numpy as np -from AnyQt.QtCore import QPointF, Qt -from AnyQt.QtGui import QStandardItemModel, QStandardItem +from AnyQt.QtCore import QPointF, Qt, QSize +from AnyQt.QtGui import QStandardItemModel, QStandardItem, \ + QPainter, QFontMetrics from AnyQt.QtWidgets import QGraphicsSceneHelpEvent, QToolTip, QSpinBox, \ - QComboBox + QComboBox, QGridLayout, QSizePolicy, QWidget import pyqtgraph as pg @@ -54,8 +55,8 @@ def _search( data: Table, learner: Learner, fitted_parameter_props: Learner.FittedParameter, - initial_parameters: dict, - steps: Sized, + initial_parameters: dict[str, Any], + steps: Collection[Any], progress_callback: Callable = dummy_callback ) -> FitterResults: progress_callback(0, "Calculating...") @@ -68,15 +69,15 @@ def _search( params[name] = value result = _validate(data, type(learner)(**params), scorer) scores.append((value, result)) - return scores, scorer.name, fitted_parameter_props.tick_label + return scores, scorer.name, fitted_parameter_props.label def run( data: Table, learner: Learner, fitted_parameter_props: Learner.FittedParameter, - initial_parameters: dict, - steps: Sized, + initial_parameters: dict[str, Any], + steps: Collection[Any], state: TaskState ) -> FitterResults: def callback(i: float, status: str = ""): @@ -96,7 +97,7 @@ class ParameterSetter(CommonParameterSetter): DEFAULT_ALPHA_GRID, DEFAULT_SHOW_GRID = 80, True def __init__(self, master): - self.grid_settings: dict = None + self.grid_settings: Optional[dict] = None self.master: FitterPlot = master super().__init__() @@ -145,9 +146,9 @@ class FitterPlot(PlotWidget): def __init__(self): super().__init__(enableMenu=False) - self.__bar_item_tr: pg.BarGraphItem = None - self.__bar_item_cv: pg.BarGraphItem = None - self.__data: list[ScoreType] = None + self.__bar_item_tr: Optional[pg.BarGraphItem] = None + self.__bar_item_cv: Optional[pg.BarGraphItem] = None + self.__data: Optional[list[ScoreType]] = None self.legend = self._create_legend() self.parameter_setter = ParameterSetter(self) self.setMouseEnabled(False, False) @@ -178,14 +179,14 @@ def set_data( self, scores: list[ScoreType], score_name: str, - tick_name: str + parameter_name: str ): self.__data = scores self.clear() - self.setLabel(axis="bottom", text=" ") + self.setLabel(axis="bottom", text=parameter_name) self.setLabel(axis="left", text=score_name) - ticks = [[(i, f"{tick_name}[{val}]") for i, (val, _) + ticks = [[(i, str(val)) for i, (val, _) in enumerate(scores)]] self.getAxis("bottom").setTicks(ticks) @@ -223,15 +224,15 @@ def help_event(self, ev: QGraphicsSceneHelpEvent) -> bool: text = "" if index is not None: _, scores = self.__data[index] - text = f"" \ - f"" \ - f"" \ + text = "
Train:
" \ + "" \ + "" \ f"" \ - f"" \ - f"" \ + "" \ + "" \ f"" \ - f"" \ - f"
Train:{round(scores[0], 3)}
CV:
CV:{round(scores[1], 3)}
" + "" \ + "" if text: QToolTip.showText(ev.screenPos(), text, widget=self) return True @@ -252,6 +253,54 @@ def __get_index_at(self, point: QPointF) -> Optional[int]: return None +class RangePreview(QWidget): + def __init__(self): + super().__init__() + font = self.font() + font.setPointSize(font.pointSize() - 3) + self.setFont(font) + + self.__steps: Optional[Sequence[int]] = None + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + + def minimumSizeHint(self): + return QSize(1, 20) + + def set_steps(self, steps: Optional[Sequence[int]]): + self.__steps = steps + self.update() + + def steps(self): + return self.__steps + + def paintEvent(self, event): + if not self.__steps: + return + painter = QPainter(self) + metrics = QFontMetrics(self.font()) + style = self.style() + rect = self.rect() + + # Indent by the width of the radio button indicator + rect.adjust(style.pixelMetric(style.PM_IndicatorWidth) + + style.pixelMetric(style.PM_CheckBoxLabelSpacing), 0, 0, 0) + + last_text = f", {self.__steps[-1]}" + last_width = metrics.horizontalAdvance(last_text) + + elided_text = metrics.elidedText( + "Steps: " + ", ".join(map(str, self.__steps[:-1])), + Qt.ElideRight, rect.width() - last_width) + elided_width = metrics.horizontalAdvance(elided_text) + + # Right-align by indenting by the underflow width + rect.adjust(rect.width() - elided_width - last_width, 0, 0, 0) + + painter.drawText(rect, Qt.AlignLeft, elided_text) + rect.adjust(elided_width, 0, 0, 0) + painter.drawText(rect, Qt.AlignLeft, last_text) + + class OWParameterFitter(OWWidget, ConcurrentWidgetMixin): name = "Parameter Fitter" description = "Fit learner for various values of fitting parameter." @@ -270,16 +319,18 @@ class Inputs: DEFAULT_MAXIMUM = 9 parameter_index = Setting(DEFAULT_PARAMETER_INDEX, schema_only=True) FROM_RANGE, MANUAL = range(2) - type = Setting(FROM_RANGE) - minimum = Setting(DEFAULT_MINIMUM, schema_only=True) - maximum = Setting(DEFAULT_MAXIMUM, schema_only=True) - manual_steps = Setting("", schema_only=True) + type: int = Setting(FROM_RANGE) + minimum: int = Setting(DEFAULT_MINIMUM, schema_only=True) + maximum: int = Setting(DEFAULT_MAXIMUM, schema_only=True) + manual_steps: str = Setting("", schema_only=True) auto_commit = Setting(True) class Error(OWWidget.Error): unknown_err = Msg("{}") not_enough_data = Msg(f"At least {N_FOLD} instances are needed.") incompatible_learner = Msg("{}") + manual_steps_error = Msg("Invalid list of steps for {}:\n{}") + min_max_error = Msg("Minimum must be less than maximum.") class Warning(OWWidget.Warning): no_parameters = Msg("{} has no parameters to fit.") @@ -289,12 +340,11 @@ def __init__(self): ConcurrentWidgetMixin.__init__(self) self._data: Optional[Table] = None self._learner: Optional[Learner] = None - self.graph: FitterPlot = None + self.graph: Optional[FitterPlot] = None self.__parameters_model = QStandardItemModel() - self.__combo: QComboBox = None - self.__spin_min: QSpinBox = None - self.__spin_max: QSpinBox = None - self.preview: str = "" + self.__combo: Optional[QComboBox] = None + self.__spin_min: Optional[QSpinBox] = None + self.__spin_max: Optional[QSpinBox] = None self.__pending_parameter_index = self.parameter_index \ if self.parameter_index != self.DEFAULT_PARAMETER_INDEX else None @@ -318,93 +368,152 @@ def _add_plot(self): box.layout().addWidget(self.graph) def _add_controls(self): - box = gui.vBox(self.controlArea, "Settings") - self.__combo = gui.comboBox(box, self, "parameter_index", + layout = QGridLayout() + gui.widgetBox(self.controlArea, "Settings", orientation=layout) + self.__combo = gui.comboBox(None, self, "parameter_index", model=self.__parameters_model, callback=self.__on_parameter_changed) - - buttons = gui.radioButtons(box, self, "type", - callback=self.__on_setting_changed) - - gui.appendRadioButton(buttons, "Range") - hbox = gui.indentedBox(buttons, 20, orientation=Qt.Horizontal) - kw = {"minv": -MIN_MAX_SPIN, "maxv": MIN_MAX_SPIN, - "callback": self.__on_setting_changed} - self.__spin_min = gui.spin(hbox, self, "minimum", label="Min:", **kw) - self.__spin_max = gui.spin(hbox, self, "maximum", label="Max:", **kw) - - gui.appendRadioButton(buttons, "Manual") - hbox = gui.indentedBox(box, 20, orientation=Qt.Horizontal) - gui.lineEdit(hbox, self, "manual_steps", placeholderText="10, 20, 30", - callback=self.__on_setting_changed) - - box = gui.vBox(self.controlArea, "Steps preview") - self.preview = "" - gui.label(box, self, "%(preview)s", wordWrap=True) + layout.addWidget(self.__combo, 0, 0, 1, 2) + + buttons = gui.radioButtons(None, self, "type", + callback=self.__on_type_changed) + button = gui.appendRadioButton(buttons, "Range:") + layout.addWidget(button, 1, 0) + + kw = dict(minv=-MIN_MAX_SPIN, maxv=MIN_MAX_SPIN, + alignment=Qt.AlignRight, + callback=self.__on_min_max_changed) + box = gui.hBox(None) + self.__spin_min = gui.spin(box, self, "minimum", label="From:", **kw) + layout.addWidget(box, 1, 1) + + box = gui.hBox(None) + self.__spin_max = gui.spin(box, self, "maximum", label="To:", **kw) + layout.addWidget(box, 2, 1) + + self.range_preview = RangePreview() + layout.addWidget(self.range_preview, 3, 0, 1, 2) + + gui.appendRadioButton(buttons, "Manual:") + layout.addWidget(buttons, 4, 0) + edit = gui.lineEdit(None, self, "manual_steps", + placeholderText="10, 20, 30", + callback=self.__on_manual_changed) + layout.addWidget(edit, 4, 1) + + # gui.lineEdit's connect does not call the callback on return pressed + # if the line hasn't changed. + @edit.returnPressed.connect + def _(): + if self.type != self.MANUAL: + self.type = self.MANUAL + self.__on_type_changed() gui.rubber(self.controlArea) gui.auto_apply(self.buttonsArea, self, "auto_commit") + def __on_type_changed(self): + self._settings_changed() + def __on_parameter_changed(self): self._set_range_controls() - self.__on_setting_changed() + self._settings_changed() + + def __on_min_max_changed(self): + self.type = self.FROM_RANGE + self._settings_changed() - def __on_setting_changed(self): + def __on_manual_changed(self): + self.type = self.MANUAL + self._settings_changed() + + def _settings_changed(self): self._update_preview() self.commit.deferred() @property def fitted_parameters(self) -> list: - if not self._learner or not self._data: + if not self._learner: return [] - return self._learner.fitted_parameters(self._data) \ - if isinstance(self._learner, Fitter) \ - else self._learner.fitted_parameters() + if isinstance(self._learner, Fitter): + if not self._data: + return [] + return self._learner.fitted_parameters(self._data) + else: + return self._learner.fitted_parameters() @property def initial_parameters(self) -> dict: - if not self._learner or not self._data: + if not self._learner: return {} - return self._learner.get_params(self._data) \ - if isinstance(self._learner, Fitter) else self._learner.params + if isinstance(self._learner, Fitter): + if not self._data: + return {} + return self._learner.get_params(self._data) + return self._learner.params @property - def steps(self) -> Iterable[int]: + def steps(self) -> tuple[int]: + self.Error.min_max_error.clear() + self.Error.manual_steps_error.clear() if self.type == self.FROM_RANGE: - step = 1 - diff = self.maximum - self.minimum - if diff > 0: - exp = int(np.ceil(np.log10(diff + 1))) - 1 - step = int(10 ** exp) - return range(self.minimum, self.maximum + step, step) + return self._steps_from_range() else: - try: - return [int(s) for s in self.manual_steps.split(",")] - except ValueError: - return [] + return self._steps_from_manual() + + def _steps_from_range(self) -> tuple[int]: + if self.maximum < self.minimum: + self.Error.min_max_error() + return () + + if self.minimum == self.maximum: + return (self.minimum, ) + + diff = self.maximum - self.minimum + # This should give between 10 and 15 steps + exp = max(0, int(np.ceil(np.log10(diff / 1.5))) - 1) + step = int(10 ** exp) + return (self.minimum, + *range((self.minimum // step + 1) * step, self.maximum, step), + self.maximum) + + def _steps_from_manual(self) -> tuple[int]: + steps = self.manual_steps + if not steps: + return () + try: + steps = tuple(sorted(map(int, steps.split(",")))) + self.manual_steps = ", ".join(map(str, steps)) + return steps + except ValueError as exc: + self.Error.manual_steps_error( + self.fitted_parameters[self.parameter_index].label, + exc) + return () @Inputs.data @check_multiple_targets_input def set_data(self, data: Optional[Table]): - self.Error.not_enough_data.clear() self._data = data - if self._data and len(self._data) < N_FOLD: - self.Error.not_enough_data() - self._data = None @Inputs.learner def set_learner(self, learner: Optional[Learner]): self._learner = learner def handleNewSignals(self): - self.Warning.no_parameters.clear() - self.Error.incompatible_learner.clear() - self.Error.unknown_err.clear() + self.Warning.clear() + self.Error.clear() self.clear() + if self._data is None or self._learner is None: return + if self._data and len(self._data) < N_FOLD: + self.Error.not_enough_data() + self._data = None + return + reason = self._learner.incompatibility_reason(self._data.domain) if reason: self.Error.incompatible_learner(reason) @@ -434,7 +543,8 @@ def handleNewSignals(self): def _set_range_controls(self): param = self.fitted_parameters[self.parameter_index] - assert param.type == int + assert param.type == int, \ + "The widget currently supports only int parameters" if param.min is not None: self.__spin_min.setMinimum(param.min) @@ -454,7 +564,10 @@ def _set_range_controls(self): self.maximum = self.initial_parameters[param.name] def _update_preview(self): - self.preview = str(list(self.steps)) + if self.type == self.FROM_RANGE: + self.range_preview.set_steps(self.steps) + else: + self.range_preview.set_steps(None) def clear(self): self.cancel() @@ -463,8 +576,8 @@ def clear(self): @gui.deferred def commit(self): - if self._data is None or self._learner is None or not \ - self.fitted_parameters: + if self._data is None or self._learner is None or \ + not self.fitted_parameters or not self.steps: return self.graph.clear_all() self.start(run, self._data, self._learner, @@ -491,7 +604,7 @@ def send_report(self): parameter = self.fitted_parameters[self.parameter_index].label self.report_items("Settings", [("Parameter", parameter), - ("Range", self.preview)]) + ("Range", ", ".join(map(str, self.steps)))]) self.report_name("Plot") self.report_plot() diff --git a/Orange/widgets/evaluate/tests/test_owparameterfitter.py b/Orange/widgets/evaluate/tests/test_owparameterfitter.py index bd757e8ee7..f6ef9d88d3 100644 --- a/Orange/widgets/evaluate/tests/test_owparameterfitter.py +++ b/Orange/widgets/evaluate/tests/test_owparameterfitter.py @@ -138,16 +138,11 @@ def test_steps_preview(self): self.send_signal(self.widget.Inputs.data, self._housing) self.send_signal(self.widget.Inputs.learner, self._pls) self.wait_until_finished() - self.assertEqual(self.widget.preview, "[1, 2]") + self.assertEqual(self.widget.range_preview.steps(), (1, 2)) self.widget.controls.type.buttons[1].click() self.wait_until_finished() - self.assertEqual(self.widget.preview, "[]") - - self.widget.controls.manual_steps.setText("10, 15, 20, 25") - self.widget.controls.type.buttons[1].click() - self.wait_until_finished() - self.assertEqual(self.widget.preview, "[10, 15, 20, 25]") + self.assertIsNone(self.widget.range_preview.steps()) def test_on_parameter_changed(self): self.send_signal(self.widget.Inputs.data, self._housing) @@ -189,8 +184,12 @@ def test_fitted_parameters(self): self.assertEqual(len(self.widget.fitted_parameters), 1) self.wait_until_finished() - self.send_signal(self.widget.Inputs.data, None) - self.assertEqual(self.widget.fitted_parameters, []) + # TODO: I would remove this test + # We may someday want to output a Learner from the widget + # I changed the widget so that it doesn't necessarily require data, + # so this test fails + # self.send_signal(self.widget.Inputs.data, None) + # self.assertEqual(self.widget.fitted_parameters, []) def test_initial_parameters(self): self.assertEqual(self.widget.initial_parameters, {}) @@ -298,6 +297,97 @@ def test_send_report(self): self.wait_until_finished() self.widget.send_report() + def test_steps_from_range_error(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._heart) + self.send_signal(w.Inputs.learner, self._dummy) + w.type = w.FROM_RANGE + + w.minimum = 10 + w.maximum = 5 + self.assertEqual(w.steps, ()) + self.assertTrue(w.Error.min_max_error.is_shown()) + + w.maximum = 15 + self.assertNotEqual(w.steps, ()) + self.assertFalse(w.Error.min_max_error.is_shown()) + + w.minimum = 10 + w.maximum = 5 + w.steps + self.assertTrue(w.Error.min_max_error.is_shown()) + + self.send_signal(w.Inputs.learner, None) + self.assertFalse(w.Error.min_max_error.is_shown()) + + def test_steps_from_range_error(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._heart) + self.send_signal(w.Inputs.learner, self._dummy) + w.type = w.FROM_RANGE + + for mini, maxi, exp in [ + (1, 2, (1, 2)), + (1, 5, (1, 2, 3, 4, 5)), + (1, 10, (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)), + (2, 14, (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14)), + (2, 20, (2, 10, 20)), + (2, 22, (2, 10, 20, 22)), + (2, 10, (2, 3, 4, 5, 6, 7, 8, 9, 10)), + (2, 5, (2, 3, 4, 5)), + (2, 4, (2, 3, 4)), + (1, 1, (1,)), + (1, 50, (1, 10, 20, 30, 40, 50)), + (3, 49, (3, 10, 20, 30, 40, 49)), + (9, 31, (9, 10, 20, 30, 31)), + (90, 398, (90, 100, 200, 300, 398)), + (90, 1010, + (90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1010)), + (810, 1234, (810, 900, 1000, 1100, 1200, 1234)), + (4980, 18030, + (4980, 5000, 6000, 7000, 8000, 9000, 10000, 11000, 12000, + 13000, 14000, 15000, 16000, 17000, 18000, 18030))]: + w.minimum = mini + w.maximum = maxi + self.assertEqual(w.steps, exp, f"min={mini}, max={maxi}") + + def test_steps_from_manual_error(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._heart) + self.send_signal(w.Inputs.learner, self._dummy) + w.type = w.MANUAL + + w.manual_steps = "1, 2, 3, asdf, 4, 5" + self.assertEqual(w.steps, ()) + self.assertTrue(w.Error.manual_steps_error.is_shown()) + + w.manual_steps = "1, 2, 3, 4, 5" + self.assertNotEqual(w.steps, ()) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + w.manual_steps = "1, 2, 3, asdf, 4, 5" + w.steps + self.assertTrue(w.Error.manual_steps_error.is_shown()) + + self.send_signal(w.Inputs.learner, None) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + def test_steps_from_manual(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._heart) + self.send_signal(w.Inputs.learner, self._dummy) + w.type = w.MANUAL + + w.manual_steps = "1, 2, 3, 4, 5" + self.assertEqual(w.steps, (1, 2, 3, 4, 5)) + + w.manual_steps = "1, 2, 3, 4, 5, 6" + self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6)) + + # TODO: We may or may not want this. + w.manual_steps = "1, 2, 10, 3, 4, 123, 5, 6" + self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6, 10, 123)) + if __name__ == "__main__": unittest.main()