diff --git a/Orange/base.py b/Orange/base.py index a741b6dba44..89a2ebc073c 100644 --- a/Orange/base.py +++ b/Orange/base.py @@ -3,7 +3,7 @@ from collections.abc import Iterable import re import warnings -from typing import Callable, Dict, Optional +from typing import Callable, Optional, NamedTuple, Type import numpy as np import scipy @@ -88,6 +88,13 @@ class Learner(ReprableWithPreprocessors): #: fitting the model preprocessors = () + class FittedParameter(NamedTuple): + name: str + label: str + type: Type + min: Optional[int] = None + max: Optional[int] = None + # Note: Do not use this class attribute. # It remains here for compatibility reasons. learner_adequacy_err_msg = '' @@ -179,6 +186,10 @@ def active_preprocessors(self): self.preprocessors is not type(self).preprocessors): yield from type(self).preprocessors + @property + def fitted_parameters(self) -> list: + return [] + # pylint: disable=no-self-use def incompatibility_reason(self, _: Domain) -> Optional[str]: """Return None if a learner can fit domain or string explaining why it can not.""" @@ -883,5 +894,5 @@ def __init__(self, preprocessors=None, **kwargs): self.params = kwargs @SklLearner.params.setter - def params(self, values: Dict): + def params(self, values: dict): self._params = values diff --git a/Orange/evaluation/testing.py b/Orange/evaluation/testing.py index d4070120604..8553dc7ae0d 100644 --- a/Orange/evaluation/testing.py +++ b/Orange/evaluation/testing.py @@ -25,7 +25,7 @@ def _identity(x): def _mp_worker(fold_i, train_data, test_data, learner_i, learner, - store_models): + store_models, suppresses_exceptions=True): predicted, probs, model, failed = None, None, None, False train_time, test_time = None, None try: @@ -45,6 +45,8 @@ def _mp_worker(fold_i, train_data, test_data, learner_i, learner, test_time = time() - t0 # Different models can fail at any time raising any exception except Exception as ex: # pylint: disable=broad-except + if not suppresses_exceptions: + raise ex failed = ex return _MpResults(fold_i, learner_i, store_models and model, failed, len(test_data), predicted, probs, @@ -96,6 +98,7 @@ def __init__(self, data=None, *, row_indices=None, folds=None, score_by_folds=True, learners=None, models=None, failed=None, actual=None, predicted=None, probabilities=None, + # pylint: disable=unused-argument store_data=None, store_models=None, train_time=None, test_time=None): """ @@ -426,7 +429,8 @@ def fit(self, *args, **kwargs): DeprecationWarning) return self(*args, **kwargs) - def __call__(self, data, learners, preprocessor=None, *, callback=None): + def __call__(self, data, learners, preprocessor=None, *, callback=None, + suppresses_exceptions=True): """ Args: data (Orange.data.Table): data to be used (usually split) into @@ -435,6 +439,7 @@ def __call__(self, data, learners, preprocessor=None, *, callback=None): preprocessor (Orange.preprocess.Preprocess): preprocessor applied on training data callback (Callable): a function called to notify about the progress + suppresses_exceptions (bool): suppress the exceptions if True Returns: results (Result): results of testing @@ -457,7 +462,10 @@ def __call__(self, data, learners, preprocessor=None, *, callback=None): part_results = [] parts = np.linspace(.0, .99, len(learners) * len(indices) + 1)[1:] for progress, part in zip(parts, args_iter): - part_results.append(_mp_worker(*(part + ()))) + part_results.append( + _mp_worker(*(part + ()), + suppresses_exceptions=suppresses_exceptions) + ) callback(progress) callback(1) @@ -723,7 +731,7 @@ def __new__(cls, data=None, test_data=None, learners=None, test_data=test_data, **kwargs) def __call__(self, data, test_data, learners, preprocessor=None, - *, callback=None): + *, callback=None, suppresses_exceptions=True): """ Args: data (Orange.data.Table): training data @@ -732,6 +740,7 @@ def __call__(self, data, test_data, learners, preprocessor=None, preprocessor (Orange.preprocess.Preprocess): preprocessor applied on training data callback (Callable): a function called to notify about the progress + suppresses_exceptions (bool): suppress the exceptions if True Returns: results (Result): results of testing @@ -746,7 +755,7 @@ def __call__(self, data, test_data, learners, preprocessor=None, for (learner_i, learner) in enumerate(learners): part_results.append( _mp_worker(0, train_data, test_data, learner_i, learner, - self.store_models)) + self.store_models, suppresses_exceptions)) callback((learner_i + 1) / len(learners)) callback(1) @@ -778,13 +787,14 @@ def __new__(cls, data=None, learners=None, preprocessor=None, **kwargs): **kwargs) def __call__(self, data, learners, preprocessor=None, *, callback=None, - **kwargs): + suppresses_exceptions=True, **kwargs): kwargs.setdefault("test_data", data) # if kwargs contains anything besides test_data, this will be detected # (and complained about) by super().__call__ return super().__call__( data=data, learners=learners, preprocessor=preprocessor, - callback=callback, **kwargs) + callback=callback, suppresses_exceptions=suppresses_exceptions, + **kwargs) def sample(table, n=0.7, stratified=False, replace=False, diff --git a/Orange/modelling/randomforest.py b/Orange/modelling/randomforest.py index cbffcca1409..ad7f8cb19d7 100644 --- a/Orange/modelling/randomforest.py +++ b/Orange/modelling/randomforest.py @@ -1,4 +1,4 @@ -from Orange.base import RandomForestModel +from Orange.base import RandomForestModel, Learner from Orange.classification import RandomForestLearner as RFClassification from Orange.data import Variable from Orange.modelling import SklFitter @@ -24,3 +24,8 @@ class RandomForestLearner(SklFitter, _FeatureScorerMixin): 'regression': RFRegression} __returns__ = RandomForestModel + + @property + def fitted_parameters(self) -> list[Learner.FittedParameter]: + return [self.FittedParameter("n_estimators", "Number of trees", + int, 1, None)] diff --git a/Orange/regression/pls.py b/Orange/regression/pls.py index 28c5dc084a0..2830c0b05ae 100644 --- a/Orange/regression/pls.py +++ b/Orange/regression/pls.py @@ -1,10 +1,9 @@ -from typing import Tuple - import numpy as np import scipy.stats as ss import sklearn.cross_decomposition as skl_pls from sklearn.preprocessing import StandardScaler +from Orange.base import Learner from Orange.data import Table, Domain, Variable, \ ContinuousVariable, StringVariable from Orange.data.util import get_unique_names, SharedComputeValue @@ -163,11 +162,11 @@ def coefficients_table(self): return coef_table @property - def rotations(self) -> Tuple[np.ndarray, np.ndarray]: + def rotations(self) -> tuple[np.ndarray, np.ndarray]: return self.skl_model.x_rotations_, self.skl_model.y_rotations_ @property - def loadings(self) -> Tuple[np.ndarray, np.ndarray]: + def loadings(self) -> tuple[np.ndarray, np.ndarray]: return self.skl_model.x_loadings_, self.skl_model.y_loadings_ def residuals_normal_probability(self, data: Table) -> Table: @@ -256,6 +255,11 @@ def incompatibility_reason(self, domain): reason = "Only numeric target variables expected." return reason + @property + def fitted_parameters(self) -> list[Learner.FittedParameter]: + return [self.FittedParameter("n_components", "Components", + int, 1, None)] + if __name__ == '__main__': import Orange diff --git a/Orange/regression/tests/test_pls.py b/Orange/regression/tests/test_pls.py index 3b72e36e27a..bc053d9cee7 100644 --- a/Orange/regression/tests/test_pls.py +++ b/Orange/regression/tests/test_pls.py @@ -21,6 +21,11 @@ def table(rows, attr, variables): class TestPLSRegressionLearner(unittest.TestCase): + def test_fitted_parameters(self): + fitted_parameters = PLSRegressionLearner().fitted_parameters + self.assertIsInstance(fitted_parameters, list) + self.assertEqual(len(fitted_parameters), 1) + def test_allow_y_dim(self): """ The current PLS version allows only a single Y dimension. """ learner = PLSRegressionLearner(n_components=2) diff --git a/Orange/widgets/evaluate/icons/ParameterFitter.svg b/Orange/widgets/evaluate/icons/ParameterFitter.svg new file mode 100644 index 00000000000..9ee52e92dae --- /dev/null +++ b/Orange/widgets/evaluate/icons/ParameterFitter.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/evaluate/owparameterfitter.py b/Orange/widgets/evaluate/owparameterfitter.py new file mode 100644 index 00000000000..bdf453ac23f --- /dev/null +++ b/Orange/widgets/evaluate/owparameterfitter.py @@ -0,0 +1,657 @@ +from typing import Optional, Callable, Collection, Sequence + +import numpy as np +from AnyQt.QtCore import QPointF, Qt, QSize +from AnyQt.QtGui import QStandardItemModel, QStandardItem, \ + QPainter, QFontMetrics +from AnyQt.QtWidgets import QGraphicsSceneHelpEvent, QToolTip, \ + QGridLayout, QSizePolicy, QWidget + +import pyqtgraph as pg + +from orangewidget.utils.itemmodels import signal_blocking +from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog, \ + KeyType, ValueType + +from Orange.base import Learner +from Orange.data import Table +from Orange.evaluation import CrossValidation, TestOnTrainingData, Results +from Orange.evaluation.scoring import Score, AUC, R2 +from Orange.modelling import Fitter +from Orange.util import dummy_callback +from Orange.widgets import gui +from Orange.widgets.settings import Setting +from Orange.widgets.utils import userinput +from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState +from Orange.widgets.utils.multi_target import check_multiple_targets_input +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.owscatterplotgraph import LegendItem +from Orange.widgets.visualize.utils.customizableplot import \ + CommonParameterSetter, Updater +from Orange.widgets.visualize.utils.plotutils import PlotWidget, \ + HelpEventDelegate +from Orange.widgets.widget import OWWidget, Input, Msg + +N_FOLD = 7 +MIN_MAX_SPIN = 100000 +ScoreType = tuple[int, tuple[float, float]] +# scores, score name, label +FitterResults = tuple[list[ScoreType], str, str] + + +def _validate( + data: Table, + learner: Learner, + scorer: type[Score] +) -> tuple[float, float]: + res: Results = TestOnTrainingData()(data, [learner], + suppresses_exceptions=False) + res_cv: Results = CrossValidation(k=N_FOLD)(data, [learner], + suppresses_exceptions=False) + # pylint: disable=unsubscriptable-object + return scorer(res)[0], scorer(res_cv)[0] + + +def _search( + data: Table, + learner: Learner, + fitted_parameter_props: Learner.FittedParameter, + initial_parameters: dict[str, int], + steps: Collection[int], + progress_callback: Callable = dummy_callback +) -> FitterResults: + progress_callback(0, "Calculating...") + scores = [] + scorer = AUC if data.domain.has_discrete_class else R2 + name = fitted_parameter_props.name + for i, value in enumerate(steps): + progress_callback(i / len(steps)) + params = initial_parameters.copy() + params[name] = value + result = _validate(data, type(learner)(**params), scorer) + scores.append((value, result)) + return scores, scorer.name, fitted_parameter_props.label + + +def run( + data: Table, + learner: Learner, + fitted_parameter_props: Learner.FittedParameter, + initial_parameters: dict[str, int], + steps: Collection[int], + state: TaskState +) -> FitterResults: + def callback(i: float, status: str = ""): + state.set_progress_value(i * 100) + if status: + state.set_status(status) + if state.is_interruption_requested(): + # pylint: disable=broad-exception-raised + raise Exception + + return _search(data, learner, fitted_parameter_props, initial_parameters, + steps, callback) + + +class ParameterSetter(CommonParameterSetter): + GRID_LABEL, SHOW_GRID_LABEL = "Gridlines", "Show" + DEFAULT_ALPHA_GRID, DEFAULT_SHOW_GRID = 80, True + + def __init__(self, master): + self.grid_settings: Optional[dict] = None + self.master: FitterPlot = master + super().__init__() + + def update_setters(self): + self.grid_settings = { + Updater.ALPHA_LABEL: self.DEFAULT_ALPHA_GRID, + self.SHOW_GRID_LABEL: self.DEFAULT_SHOW_GRID, + } + + self.initial_settings = { + self.LABELS_BOX: { + self.FONT_FAMILY_LABEL: self.FONT_FAMILY_SETTING, + self.AXIS_TITLE_LABEL: self.FONT_SETTING, + self.AXIS_TICKS_LABEL: self.FONT_SETTING, + self.LEGEND_LABEL: self.FONT_SETTING, + }, + self.PLOT_BOX: { + self.GRID_LABEL: { + self.SHOW_GRID_LABEL: (None, True), + Updater.ALPHA_LABEL: (range(0, 255, 5), + self.DEFAULT_ALPHA_GRID), + }, + }, + } + + def update_grid(**settings): + self.grid_settings.update(**settings) + self.master.showGrid( + x=False, y=self.grid_settings[self.SHOW_GRID_LABEL], + alpha=self.grid_settings[Updater.ALPHA_LABEL] / 255) + + self._setters[self.PLOT_BOX] = {self.GRID_LABEL: update_grid} + + @property + def axis_items(self): + return [value["item"] for value in + self.master.getPlotItem().axes.values()] + + @property + def legend_items(self): + return self.master.legend.items + + +class FitterPlot(PlotWidget): + BAR_WIDTH = 0.4 + + def __init__(self): + super().__init__(enableMenu=False) + 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) + self.hideButtons() + + self.showGrid(x=False, y=self.parameter_setter.DEFAULT_SHOW_GRID, + alpha=self.parameter_setter.DEFAULT_ALPHA_GRID / 255) + + self.tooltip_delegate = HelpEventDelegate(self.help_event) + self.scene().installEventFilter(self.tooltip_delegate) + + def _create_legend(self) -> LegendItem: + legend = LegendItem() + legend.setParentItem(self.getViewBox()) + legend.anchor((1, 1), (1, 1), offset=(-5, -5)) + legend.hide() + return legend + + def clear_all(self): + self.clear() + self.__bar_item_tr = None + self.__bar_item_cv = None + self.__data = None + self.setLabel(axis="bottom", text=None) + self.setLabel(axis="left", text=None) + self.getAxis("bottom").setTicks(None) + + def set_data( + self, + scores: list[ScoreType], + score_name: str, + parameter_name: str + ): + self.__data = scores + self.clear() + self.setLabel(axis="bottom", text=parameter_name) + self.setLabel(axis="left", text=score_name) + + ticks = [[(i, str(val)) for i, (val, _) + in enumerate(scores)]] + self.getAxis("bottom").setTicks(ticks) + + brush_tr = "#6fa255" + brush_cv = "#3a78b6" + pen = pg.mkPen("#333") + kwargs = {"pen": pen, "width": self.BAR_WIDTH} + bar_item_tr = pg.BarGraphItem(x=np.arange(len(scores)) - 0.2, + height=[(s[0]) for _, s in scores], + brush=brush_tr, **kwargs) + bar_item_cv = pg.BarGraphItem(x=np.arange(len(scores)) + 0.2, + height=[(s[1]) for _, s in scores], + brush=brush_cv, **kwargs) + self.addItem(bar_item_tr) + self.addItem(bar_item_cv) + self.__bar_item_tr = bar_item_tr + self.__bar_item_cv = bar_item_cv + + self.legend.clear() + kwargs = {"pen": pen, "symbol": "s"} + scatter_item_tr = pg.ScatterPlotItem(brush=brush_tr, **kwargs) + scatter_item_cv = pg.ScatterPlotItem(brush=brush_cv, **kwargs) + self.legend.addItem(scatter_item_tr, "Train") + self.legend.addItem(scatter_item_cv, "CV") + Updater.update_legend_font(self.legend.items, + **self.parameter_setter.legend_settings) + self.legend.show() + + def help_event(self, ev: QGraphicsSceneHelpEvent) -> bool: + if self.__bar_item_tr is None: + return False + + pos = self.__bar_item_tr.mapFromScene(ev.scenePos()) + index = self.__get_index_at(pos) + text = "" + if index is not None: + _, scores = self.__data[index] + text = "" \ + "" \ + "" \ + f"" \ + "" \ + "" \ + f"" \ + "" \ + "
Train:{round(scores[0], 3)}
CV:{round(scores[1], 3)}
" + if text: + QToolTip.showText(ev.screenPos(), text, widget=self) + return True + else: + return False + + def __get_index_at(self, point: QPointF) -> Optional[int]: + x = point.x() + index = round(x) + # pylint: disable=unsubscriptable-object + heights_tr: list = self.__bar_item_tr.opts["height"] + heights_cv: list = self.__bar_item_cv.opts["height"] + if 0 <= index < len(heights_tr) and abs(index - x) <= self.BAR_WIDTH: + if index > x and 0 <= point.y() <= heights_tr[index]: + return index + if x > index and 0 <= point.y() <= heights_cv[index]: + return index + 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, _): + 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]}" + if len(self.__steps) > 1: + last_text = ", " + last_text + 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." + icon = "icons/ParameterFitter.svg" + priority = 1110 + + visual_settings = Setting({}, schema_only=True) + graph_name = "graph.plotItem" + + class Inputs: + data = Input("Data", Table) + learner = Input("Learner", Learner) + + DEFAULT_PARAMETER_INDEX = 0 + DEFAULT_MINIMUM = 1 + DEFAULT_MAXIMUM = 9 + parameter_index = Setting(DEFAULT_PARAMETER_INDEX, schema_only=True) + FROM_RANGE, MANUAL = range(2) + 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 values for '{}': {}") + min_max_error = Msg("Minimum must be less than maximum.") + missing_target = Msg("Data has no target.") + + class Warning(OWWidget.Warning): + no_parameters = Msg("{} has no parameters to fit.") + + def __init__(self): + OWWidget.__init__(self) + ConcurrentWidgetMixin.__init__(self) + self._data: Optional[Table] = None + self._learner: Optional[Learner] = None + self.__parameters_model = QStandardItemModel() + self.__initialize_settings = False + + self.setup_gui() + VisualSettingsDialog( + self, self.graph.parameter_setter.initial_settings + ) + + def setup_gui(self): + self._add_plot() + self._add_controls() + + def _add_plot(self): + # This is a part of __init__ + # pylint: disable=attribute-defined-outside-init + box = gui.vBox(self.mainArea) + self.graph = FitterPlot() + box.layout().addWidget(self.graph) + + def _add_controls(self): + # This is a part of __init__ + # pylint: disable=attribute-defined-outside-init + 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) + 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) + + # pylint: disable=use-dict-literal + 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) + self.edit = gui.lineEdit(None, self, "manual_steps", + placeholderText="e.g. 10, 20, ..., 50", + callback=self.__on_manual_changed) + layout.addWidget(self.edit, 4, 1) + + # gui.lineEdit's connect does not call the callback on return pressed + # if the line hasn't changed. + @self.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") + + self._update_preview() + + def __on_type_changed(self): + self._settings_changed() + + def __on_parameter_changed(self): + self.__initialize_settings = True + self._set_range_controls(self.fitted_parameters[self.parameter_index]) + self._settings_changed() + + def __on_min_max_changed(self): + self.type = self.FROM_RANGE + self._settings_changed() + + 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: + return [] + return self._learner.fitted_parameters + + @property + def initial_parameters(self) -> dict: + if not self._learner: + return {} + if isinstance(self._learner, Fitter): + return self._learner.get_params(self._data or "classification") + return self._learner.params + + @property + def steps(self) -> tuple[int, ...]: + self.Error.min_max_error.clear() + self.Error.manual_steps_error.clear() + + if self.type == self.FROM_RANGE: + return self._steps_from_range() + else: + 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, ...]: + param = self.fitted_parameters[self.parameter_index] + try: + steps = userinput.numbers_from_list( + self.manual_steps, int, param.min, param.max) + except ValueError as ex: + self.Error.manual_steps_error(param.label, ex) + return () + if steps and "..." not in self.manual_steps: + self.manual_steps = ", ".join(map(str, steps)) + return steps + + @Inputs.data + @check_multiple_targets_input + def set_data(self, data: Optional[Table]): + self.Error.not_enough_data.clear() + self.Error.missing_target.clear() + self._data = data + if self._data and len(self._data) < N_FOLD: + self.Error.not_enough_data() + self._data = None + if self._data and len(self._data.domain.class_vars) < 1: + self.Error.missing_target() + self._data = None + + @Inputs.learner + def set_learner(self, learner: Optional[Learner]): + self.Warning.clear() + self.Error.manual_steps_error.clear() + self.Error.min_max_error.clear() + self.__parameters_model.clear() + + if not learner: + self.__initialize_settings = False + # reset spin controls + ars = (None, None, int, None, None) + self._set_range_controls(Learner.FittedParameter(*ars)) + + elif self._learner: + self.__initialize_settings = \ + learner.fitted_parameters != self.fitted_parameters + + else: + # changed by user or opened workflow + self.__initialize_settings = \ + self.parameter_index == self.DEFAULT_PARAMETER_INDEX and \ + self.minimum == self.DEFAULT_MINIMUM and \ + self.maximum == self.DEFAULT_MAXIMUM + + self._learner = learner + if self._learner is None: + return + + for param in self.fitted_parameters: + item = QStandardItem(param.label) + self.__parameters_model.appendRow(item) + + if not self.fitted_parameters: + self.Warning.no_parameters(self._learner.name) + else: + if self.__initialize_settings: + self.parameter_index = 0 + else: + self.__combo.setCurrentIndex(self.parameter_index) + self._set_range_controls( + self.fitted_parameters[self.parameter_index]) + + self._update_preview() + + def handleNewSignals(self): + self.Error.unknown_err.clear() + self.Error.incompatible_learner.clear() + self.clear() + + if not self._data or not self._learner: + return + + reason = self._learner.incompatibility_reason(self._data.domain) + if reason: + self.Error.incompatible_learner(reason) + return + + self.commit.now() + + def _set_range_controls(self, param: Learner.FittedParameter): + assert param.type == int, \ + "The widget currently supports only int parameters" + + # Block signals to avoid changing `self.type` + with signal_blocking(self.__spin_min), signal_blocking(self.__spin_max): + if param.min is not None: + self.__spin_min.setMinimum(param.min) + self.__spin_max.setMinimum(param.min) + self.minimum = param.min if self.__initialize_settings else \ + max(self.minimum, param.min) + else: + self.__spin_min.setMinimum(-MIN_MAX_SPIN) + self.__spin_max.setMinimum(-MIN_MAX_SPIN) + if self.__initialize_settings: + self.minimum = self.initial_parameters[param.name] + if param.max is not None: + self.__spin_min.setMaximum(param.max) + self.__spin_max.setMaximum(param.max) + if self.__initialize_settings: + self.maximum = param.max + self.maximum = param.max if self.__initialize_settings else \ + min(self.maximum, param.max) + else: + self.__spin_min.setMaximum(MIN_MAX_SPIN) + self.__spin_max.setMaximum(MIN_MAX_SPIN) + if self.__initialize_settings: + self.maximum = self.initial_parameters[param.name] + self.__initialize_settings = False + + tip = "Enter a list of values" + if param.min is not None: + if param.max is not None: + self.edit.setToolTip(f"{tip} between {param.min} and {param.max}.") + else: + self.edit.setToolTip(f"{tip} greater or equal to {param.min}.") + elif param.max is not None: + self.edit.setToolTip(f"{tip} smaller or equal to {param.max}.") + else: + self.edit.setToolTip("") + + def _update_preview(self): + 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() + self.graph.clear_all() + + @gui.deferred + def commit(self): + self.graph.clear_all() + if self._data is None or self._learner is None or \ + not self.fitted_parameters or not self.steps: + return + self.start(run, self._data, self._learner, + self.fitted_parameters[self.parameter_index], + self.initial_parameters, self.steps) + + def on_done(self, result: FitterResults): + self.graph.set_data(*result) + + def on_exception(self, ex: Exception): + self.Error.unknown_err(ex) + + def on_partial_result(self, _): + pass + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() + + def send_report(self): + if self._data is None or self._learner is None \ + or not self.fitted_parameters: + return + parameter = self.fitted_parameters[self.parameter_index].label + self.report_items("Settings", + [("Parameter", parameter), + ("Range", ", ".join(map(str, self.steps)))]) + self.report_name("Plot") + self.report_plot() + + def set_visual_settings(self, key: KeyType, value: ValueType): + self.graph.parameter_setter.set_parameter(key, value) + # pylint: disable=unsupported-assignment-operation + self.visual_settings[key] = value + + +if __name__ == "__main__": + from Orange.regression import PLSRegressionLearner + + WidgetPreview(OWParameterFitter).run( + set_data=Table("housing"), set_learner=PLSRegressionLearner()) diff --git a/Orange/widgets/evaluate/owpermutationplot.py b/Orange/widgets/evaluate/owpermutationplot.py index 882d7fe52b1..37e301deb50 100644 --- a/Orange/widgets/evaluate/owpermutationplot.py +++ b/Orange/widgets/evaluate/owpermutationplot.py @@ -50,11 +50,10 @@ def _validate( learner: Learner, scorer: Score ) -> Tuple[float, float]: - # dummy call - Validation would silence the exceptions - learner(data) - - res: Results = TestOnTrainingData()(data, [learner]) - res_cv: Results = CrossValidation(k=N_FOLD)(data, [learner]) + res: Results = TestOnTrainingData()(data, [learner], + suppresses_exceptions=False) + res_cv: Results = CrossValidation(k=N_FOLD)(data, [learner], + suppresses_exceptions=False) # pylint: disable=unsubscriptable-object return scorer(res)[0], scorer(res_cv)[0] diff --git a/Orange/widgets/evaluate/tests/test_owparameterfitter.py b/Orange/widgets/evaluate/tests/test_owparameterfitter.py new file mode 100644 index 00000000000..f597fc8509c --- /dev/null +++ b/Orange/widgets/evaluate/tests/test_owparameterfitter.py @@ -0,0 +1,647 @@ +# pylint: disable=missing-docstring,protected-access +import unittest +from unittest.mock import patch, Mock + +import pyqtgraph as pg + +from AnyQt.QtCore import QPointF +from AnyQt.QtGui import QFont +from AnyQt.QtWidgets import QToolTip + +from Orange.classification import NaiveBayesLearner +from Orange.data import Table, Domain +from Orange.modelling import RandomForestLearner +from Orange.regression import PLSRegressionLearner +from Orange.widgets.evaluate.owparameterfitter import OWParameterFitter +from Orange.widgets.model.owrandomforest import OWRandomForest +from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import simulate + + +class DummyLearner(PLSRegressionLearner): + @property + def fitted_parameters(self): + return [ + self.FittedParameter("n_components", "Foo", int, 5, None), + self.FittedParameter("n_components", "Bar", int, 5, 10), + self.FittedParameter("n_components", "Baz", int, None, 10), + self.FittedParameter("n_components", "Qux", int, None, None) + ] + + +class TestOWParameterFitter(WidgetTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._heart = Table("heart_disease") + cls._housing = Table("housing") + cls._naive_bayes = NaiveBayesLearner() + cls._pls = PLSRegressionLearner() + cls._rf = RandomForestLearner() + cls._dummy = DummyLearner() + + def setUp(self): + self.widget = self.create_widget(OWParameterFitter) + + def test_init(self): + self.widget.controls.minimum.setValue(3) + self.widget.controls.maximum.setValue(6) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.assertEqual(self.widget.controls.parameter_index.currentText(), + "Components") + self.assertEqual(self.widget.minimum, 3) + self.assertEqual(self.widget.maximum, 6) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertEqual(self.widget.controls.parameter_index.currentText(), + "") + + def test_input(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + + self.send_signal(self.widget.Inputs.data, self._heart) + self.wait_until_finished() + self.assertTrue(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + def test_input_no_params(self): + self.send_signal(self.widget.Inputs.data, self._heart) + self.send_signal(self.widget.Inputs.learner, self._naive_bayes) + self.wait_until_finished() + self.assertTrue(self.widget.Warning.no_parameters.is_shown()) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + + def test_random_forest(self): + rf_widget = self.create_widget(OWRandomForest) + learner = self.get_output(rf_widget.Outputs.learner) + + self.send_signal(self.widget.Inputs.learner, learner) + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.data, self._heart) + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + def test_classless_data(self): + data = self._housing + classless_data = data.transform(Domain(data.domain.attributes)) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, classless_data) + self.wait_until_finished() + self.assertTrue(self.widget.Error.missing_target.is_shown()) + + self.send_signal(self.widget.Inputs.data, data) + self.wait_until_finished() + self.assertFalse(self.widget.Error.missing_target.is_shown()) + + def test_multiclass_data(self): + data = self._housing + multiclass_data = data.transform(Domain(data.domain.attributes[2:], + data.domain.attributes[:2])) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, multiclass_data) + self.wait_until_finished() + self.assertTrue(self.widget.Error.multiple_targets_data.is_shown()) + + self.send_signal(self.widget.Inputs.data, data) + self.wait_until_finished() + self.assertFalse(self.widget.Error.multiple_targets_data.is_shown()) + + def test_plot(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + + x = self.widget.graph._FitterPlot__bar_item_tr.opts["x"] + self.assertEqual(list(x), [-0.2, 0.8]) + x = self.widget.graph._FitterPlot__bar_item_cv.opts["x"] + self.assertEqual(list(x), [0.2, 1.2]) + + @patch.object(QToolTip, "showText") + def test_tooltip(self, show_text): + graph = self.widget.graph + + self.assertFalse(self.widget.graph.help_event(Mock())) + self.assertIsNone(show_text.call_args) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + + for item in graph.items(): + if isinstance(item, pg.BarGraphItem): + item.mapFromScene = Mock(return_value=QPointF(0.2, 0.2)) + + self.assertTrue(self.widget.graph.help_event(Mock())) + self.assertIn("Train:", show_text.call_args[0][1]) + self.assertIn("CV:", show_text.call_args[0][1]) + + for item in graph.items(): + if isinstance(item, pg.BarGraphItem): + item.mapFromScene = Mock(return_value=QPointF(0.5, 0.5)) + self.assertFalse(self.widget.graph.help_event(Mock())) + + def test_manual_steps(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + + self.widget.controls.manual_steps.setText("1, 2, 3") + self.widget.controls.type.buttons[1].click() + self.wait_until_finished() + + x = self.widget.graph._FitterPlot__bar_item_tr.opts["x"] + self.assertEqual(list(x), [-0.2, 0.8, 1.8]) + x = self.widget.graph._FitterPlot__bar_item_cv.opts["x"] + self.assertEqual(list(x), [0.2, 1.2, 2.2]) + + def test_manual_steps_limits(self): + w = self.widget + + def check(cases): + for setting, steps in cases: + w.controls.manual_steps.setText(setting) + w.controls.manual_steps.returnPressed.emit() + self.assertEqual(w.steps, steps, f"setting: {setting}") + self.assertIs(w.Error.manual_steps_error.is_shown(), not steps, + f"setting: {setting}") + + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.wait_until_finished() + + # 5 to None + simulate.combobox_activate_index(w.controls.parameter_index, 0) + self.wait_until_finished() + check([("6, 9, 7", (6, 7, 9)), + ("6, 9, 7, 3", ()), + ("6, 9, 7", (6, 7, 9)), + ("6, 9, 7, 3", ())]) + + # None to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 2) + self.wait_until_finished() + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + check([("12, 1, 3, -5", ()), + ("1, 3, -5", (-5, 1, 3)), + ("12, 1, 3, -5", ())]) + + # No limits + simulate.combobox_activate_index(w.controls.parameter_index, 3) + self.wait_until_finished() + + self.assertEqual(w.steps, (-5, 1, 3, 12)) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + # 5 to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.wait_until_finished() + + self.assertEqual(w.steps, ()) + self.assertTrue(w.Error.manual_steps_error.is_shown()) + + check([("12, 8, 7, 5", ()), + ("8, 7, -5", ()), + ("8, 7, 5", (5, 7, 8))]) + + 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.range_preview.steps(), (1, 2)) + + self.widget.controls.type.buttons[1].click() + self.wait_until_finished() + self.assertIsNone(self.widget.range_preview.steps()) + + def test_on_parameter_changed(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._dummy) + self.wait_until_finished() + + self.widget.commit.deferred = Mock() + + for i in range(1, 4): + self.widget.commit.deferred.reset_mock() + simulate.combobox_activate_index( + self.widget.controls.parameter_index, i) + self.wait_until_finished() + self.widget.commit.deferred.assert_called_once() + + def test_not_enough_data(self): + self.send_signal(self.widget.Inputs.data, self._housing[:5]) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + self.assertTrue(self.widget.Error.not_enough_data.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + + def test_unknown_err(self): + self.send_signal(self.widget.Inputs.data, Table("iris")[:50]) + self.send_signal(self.widget.Inputs.learner, self._rf) + self.wait_until_finished() + self.assertTrue(self.widget.Error.unknown_err.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + + def test_fitted_parameters(self): + self.assertEqual(self.widget.fitted_parameters, []) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.assertEqual(self.widget.fitted_parameters, []) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.assertEqual(len(self.widget.fitted_parameters), 1) + self.wait_until_finished() + + def test_initial_parameters(self): + self.assertEqual(self.widget.initial_parameters, {}) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.assertEqual(self.widget.initial_parameters, {}) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.assertEqual(len(self.widget.initial_parameters), 3) + self.wait_until_finished() + + self.send_signal(self.widget.Inputs.learner, self._rf) + self.assertEqual(len(self.widget.initial_parameters), 13) + self.wait_until_finished() + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(len(self.widget.initial_parameters), 14) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertEqual(self.widget.initial_parameters, {}) + + def test_bounds(self): + self.widget.controls.minimum.setValue(-3) + self.widget.controls.maximum.setValue(6) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, None) + self.widget.controls.minimum.setValue(-3) + self.widget.controls.maximum.setValue(6) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + + def test_saved_workflow(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._dummy) + self.wait_until_finished() + simulate.combobox_activate_index( + self.widget.controls.parameter_index, 2) + self.widget.controls.minimum.setValue(3) + self.widget.controls.maximum.setValue(6) + self.wait_until_finished() + + settings = self.widget.settingsHandler.pack_data(self.widget) + widget = self.create_widget(OWParameterFitter, + stored_settings=settings) + self.send_signal(widget.Inputs.data, self._housing, widget=widget) + self.send_signal(widget.Inputs.learner, self._dummy, widget=widget) + self.wait_until_finished(widget=widget) + self.assertEqual(widget.controls.parameter_index.currentText(), "Baz") + self.assertEqual(widget.minimum, 3) + self.assertEqual(widget.maximum, 6) + + def test_retain_settings(self): + self.send_signal(self.widget.Inputs.learner, self._dummy) + + controls = self.widget.controls + + def _test(): + self.assertEqual(controls.parameter_index.currentText(), "Bar") + self.assertEqual(controls.minimum.value(), 6) + self.assertEqual(controls.maximum.value(), 8) + self.assertEqual(self.widget.parameter_index, 1) + self.assertEqual(self.widget.minimum, 6) + self.assertEqual(self.widget.maximum, 8) + + simulate.combobox_activate_index(controls.parameter_index, 1) + controls.minimum.setValue(6) + controls.maximum.setValue(8) + + self.send_signal(self.widget.Inputs.data, self._housing) + _test() + + self.send_signal(self.widget.Inputs.learner, + DummyLearner(n_components=6)) + _test() + + self.send_signal(self.widget.Inputs.data, None) + self.send_signal(self.widget.Inputs.data, self._housing) + _test() + + self.send_signal(self.widget.Inputs.learner, self._rf) + self.assertEqual(controls.parameter_index.currentText(), + "Number of trees") + self.assertEqual(controls.minimum.value(), 1) + self.assertEqual(controls.maximum.value(), 10) + self.assertEqual(self.widget.parameter_index, 0) + self.assertEqual(self.widget.minimum, 1) + self.assertEqual(self.widget.maximum, 10) + + def test_visual_settings(self): + graph = self.widget.graph + + def test_settings(): + font = QFont("Helvetica", italic=True, pointSize=20) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.label.font(), font) + font.setPointSize(15) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.style["tickFont"], font) + font.setPointSize(17) + for legend_item in graph.parameter_setter.legend_items: + self.assertFontEqual(legend_item[1].item.font(), font) + self.assertFalse(graph.getAxis("left").grid) + + key, value = ("Fonts", "Font family", "Font family"), "Helvetica" + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Axis title", "Font size"), 20 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis title", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Axis ticks", "Font size"), 15 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis ticks", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Legend", "Font size"), 17 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Legend", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Figure", "Gridlines", "Show"), False + self.widget.set_visual_settings(key, value) + key, value = ("Figure", "Gridlines", "Opacity"), 20 + self.widget.set_visual_settings(key, value) + + test_settings() + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, self._heart[:10]) + test_settings() + + self.send_signal(self.widget.Inputs.data, None) + self.send_signal(self.widget.Inputs.learner, None) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, self._heart[:10]) + test_settings() + + def assertFontEqual(self, font1: QFont, font2: QFont): + self.assertEqual(font1.family(), font2.family()) + self.assertEqual(font1.pointSize(), font2.pointSize()) + self.assertEqual(font1.italic(), font2.italic()) + + def test_send_report(self): + self.widget.send_report() + + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + self.widget.send_report() + + self.send_signal(self.widget.Inputs.data, self._heart) + self.send_signal(self.widget.Inputs.learner, self._naive_bayes) + 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 # pylint: disable=pointless-statement + 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_ranges_steps(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._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.wait_until_finished() + simulate.combobox_activate_index(w.controls.parameter_index, 3) + 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 # pylint: disable=pointless-statement + 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_no_dots(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.wait_until_finished() + simulate.combobox_activate_index(w.controls.parameter_index, 3) + 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)) + + w.manual_steps = "1, 2, 10, 3, 4, 123, 5, 6" + self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6, 10, 123)) + + def test_steps_from_manual_dots(self): + def check(cases): + for settings, steps in cases: + w.manual_steps = settings + self.assertEqual(w.steps, steps, f"setting: {settings}") + self.assertIs(w.Error.manual_steps_error.is_shown(), not steps, + f"setting: {settings}") + + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.wait_until_finished() + w.type = w.MANUAL + + # No limits + simulate.combobox_activate_index(w.controls.parameter_index, 3) + self.wait_until_finished() + check([("1, 2, ..., 5", (1, 2, 3, 4, 5)), + ("1, 2, 3, ..., 5, 6, 7", (1, 2, 3, 4, 5, 6, 7)), + ("3, ..., 5, 6", (3, 4, 5, 6)), + ("..., 5, 6", ()), + ("5, 6, ...", ()), + ("1, 2, 3, 4, 5, ...", ()), + ("1, ..., 5", ()), + ("1, 2, ..., 5, 6, ..., 8", ())]) + + # 5 to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.wait_until_finished() + check([("4, 5, ..., 8", ()), + ("5, 6, ..., 12", ()), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ]) + + # 5 to None + simulate.combobox_activate_index(w.controls.parameter_index, 0) + self.wait_until_finished() + check([("4, 5, ..., 8", ()), + ("5, 6, ..., 12", (5, 6, 7, 8, 9, 10, 11, 12)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", ()) + ]) + + # None to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 2) + self.wait_until_finished() + check([("4, 5, ..., 8", (4, 5, 6, 7, 8)), + ("5, 6, ..., 12", ()), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("..., 8, 9", ()), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", ()), + ]) + + def test_steps_from_manual_dots_corrections(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.wait_until_finished() + w.type = w.MANUAL + + # 5 to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.wait_until_finished() + + for settings, steps in [ + ("5, 6..., 8", (5, 6, 7, 8)), + ("5,6...,8", (5, 6, 7, 8)), + ("5,6...8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5, 6 ... ", (5, 6, 7, 8, 9, 10)), + ("..., 7, 8", (5, 6, 7, 8)), + ("..., 7, 8, ...", ()), + ("5, 6, ..., 7, 8, ...", ()), + ("5, 6, 8, ...", ()), + ("5, 6, 8, ...", ()), + ("5, 6, ..., 8, 10", ()), + ("5, 7, ..., 8, 10", ()), + ("8, 7, 6, ...", ()), + ("5, 6, 7, ..., 7, 8", ()), + ]: + w.manual_steps = settings + self.assertEqual(w.steps, steps, f"setting: {settings}") + self.assertIs(w.Error.manual_steps_error.is_shown(), not steps, + f"setting: {settings}") + + def test_manual_tooltip(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.wait_until_finished() + + simulate.combobox_activate_index(w.controls.parameter_index, 0) + self.assertIn("greater or equal to 5", w.edit.toolTip()) + + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.assertIn("between 5 and 10", w.edit.toolTip()) + + simulate.combobox_activate_index(w.controls.parameter_index, 2) + self.assertIn("smaller or equal to 10", w.edit.toolTip()) + + simulate.combobox_activate_index(w.controls.parameter_index, 3) + self.assertEqual("", w.edit.toolTip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/tests/test_userinput.py b/Orange/widgets/utils/tests/test_userinput.py new file mode 100644 index 00000000000..314f7a383f7 --- /dev/null +++ b/Orange/widgets/utils/tests/test_userinput.py @@ -0,0 +1,188 @@ +import unittest +from unittest.mock import patch +from Orange.widgets.utils.userinput import ( + _get_points, numbers_from_list) + + +class TestPointsFromList(unittest.TestCase): + @patch("Orange.widgets.utils.userinput._numbers_from_no_dots") + @patch("Orange.widgets.utils.userinput._numbers_from_dots") + def test_points_from_list(self, from_dots, no_dots): + numbers_from_list("1 2 3", int) + no_dots.assert_called() + no_dots.reset_mock() + from_dots.assert_not_called() + + numbers_from_list("5, 9", int) + no_dots.assert_called() + no_dots.reset_mock() + from_dots.assert_not_called() + + numbers_from_list("5.13", int) + no_dots.assert_called() + no_dots.reset_mock() + from_dots.assert_not_called() + + numbers_from_list("1 ... 3", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("1, 2, 3...5", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("...3, 4, 5", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("3, 4, 5...", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("3, 4, ...,5...", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + def test_get_points(self): + self.assertEqual(_get_points(["1", "2", "3"], int), (1, 2, 3)) + self.assertEqual(_get_points(["1", "2", "3"], float), (1, 2, 3)) + self.assertEqual(_get_points(["10.5", "2.25", "3"], float), (10.5, 2.25, 3)) + + with self.assertRaisesRegex(ValueError, "3.3"): + _get_points(["1", "2", "3.3", "4"], int) + + with self.assertRaisesRegex(ValueError, "asdf"): + _get_points(["1", "2", "asdf", "4"], int) + + def test_numbers_from_no_dots(self): + self.assertEqual(numbers_from_list("1 2 3", int), (1, 2, 3)) + self.assertEqual(numbers_from_list("1 2 3", float), (1, 2, 3)) + self.assertEqual(numbers_from_list("10.5 2.25 3", float), (2.25, 3, 10.5)) + + with self.assertRaisesRegex(ValueError, "3.3"): + numbers_from_list("1 2 3.3 4", int) + + with self.assertRaisesRegex(ValueError, "asdf"): + numbers_from_list("1 2 asdf 4", int) + + with self.assertRaisesRegex(ValueError, "value must be at least 2"): + numbers_from_list("1", int, 2) + + with self.assertRaisesRegex(ValueError, "value must be at most 2"): + numbers_from_list("3", int, None, 2) + + with self.assertRaisesRegex(ValueError, "value must be between 2 and 3"): + numbers_from_list("1 4 2 3", int, 2, 3) + + def test_numbers_from_dots(self): + def check(minimum, maximum, tests, typ=int, enforce_range=True): + for text, *expected in tests: + if not expected: + with self.assertRaises(ValueError): + numbers_from_list(text, typ, minimum, maximum, + enforce_range) + else: + self.assertEqual( + numbers_from_list(text, typ, minimum, maximum, + enforce_range), + expected[0], + f"for {text}") + + check(None, None, [ + ("1, 2, ..., 5", (1, 2, 3, 4, 5)), + ("1, 2, 3, ..., 5, 6, 7", (1, 2, 3, 4, 5, 6, 7)), + ("3, ..., 5, 6", (3, 4, 5, 6)), + ("..., 5, 6", ), + ("5, 6, ...", ), + ("1, 2, 3, 4, 5, ...", ), + ("1, ..., 5", ), + ("1, 2, ..., 5, 6, ..., 8", )]) + + # 5 to 10 + check(5, 10, [ + ("4, 5, ..., 8", ), + ("5, 6, ..., 12", ), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ]) + + check(5, None, [ + ("4, 5, ..., 8", ), + ("5, 6, ..., 12", (5, 6, 7, 8, 9, 10, 11, 12)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", ) + ]) + + check(None, 10, [ + ("4, 5, ..., 8", (4, 5, 6, 7, 8)), + ("5, 6, ..., 12", ), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("..., 8, 9", ), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", )]) + + check(5, 10, [ + ("5, 6..., 8", (5, 6, 7, 8)), + ("5,6...,8", (5, 6, 7, 8)), + ("5,6...8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5 6 ... ", (5, 6, 7, 8, 9, 10)), + ("..., 7, 8", (5, 6, 7, 8)), + ("..., 7, 8, ...", ), + ("5, 6, ..., 7, 8, ...", ), + ("5, 6, ..., 7, 8, ...", ), + ("5 6 8, ...", ), + ("5, 6, 8, ...", ), + ("5, 6, ..., 8, 10", ), + ("5, 7, ..., 8, 10", ), + ("8, 7, 6, ...", ), + ("5, 6, 7, ..., 7, 8", )]) + + check(5, 10, [ + ("3, 4, 5, 6..., 8", (3, 4, 5, 6, 7, 8)), + ("5, ..., 9, 11, 13", (5, 7, 9, 11, 13)), + ("5, 6, ..., ", (5, 6, 7, 8, 9, 10)), + ("..., 9, 10, 11", (5, 6, 7, 8, 9, 10, 11))], + int, False) + + check(1, None, [ + ("5, 6..., 8", (5, 6, 7, 8)), + ("..., 7, 9", (1, 3, 5, 7, 9)), + ("..., 8, 10", (2, 4, 6, 8, 10)), + ("..., 7, 10", (1, 4, 7, 10)), + ("..., 6, 10", (2, 6, 10)), + ("..., 5, 10", (5, 10)), + ("..., 4, 10", (4, 10)), + ]) + + check(None, 10, [ + ("2, 4, ...", (2, 4, 6, 8, 10)), + ("1, 3, ...", (1, 3, 5, 7, 9)), + ("1, 4, ...", (1, 4, 7, 10)), + ("1, 5, ...", (1, 5, 9)), + ("1, 6, ...", (1, 6)), + ]) + + check(None, None, [ + ("1.3, 1.6, ..., 2.5", (1.3, 1.6, 1.9, 2.2, 2.5)), + ("1.3, 1.6, ..., 2.55", )], + float) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/userinput.py b/Orange/widgets/utils/userinput.py new file mode 100644 index 00000000000..0adadfbf18a --- /dev/null +++ b/Orange/widgets/utils/userinput.py @@ -0,0 +1,110 @@ +from typing import Type, TypeVar, Optional +import re + +T = TypeVar("T", int, float) + + +def numbers_from_list( + text: str, + typ: Type[T], + minimum: Optional[T] = None, + maximum: Optional[T] = None, + enforce_range: Optional[bool] = True) -> tuple[T, ...]: + text = text.strip() + if not text: + return () + + if re.search(r"(^|[^.])\.\.\.($|[^.])", text): + return _numbers_from_dots(text, typ, minimum, maximum, enforce_range) + else: + if enforce_range: + return _numbers_from_no_dots(text, typ, minimum, maximum) + else: + return _numbers_from_no_dots(text, typ) + + +def _get_points(numbers: list[str], typ: Type[T]) -> tuple[T, ...]: + try: + return tuple(map(typ, numbers)) + except ValueError as exc: + msg = str(exc) + raise ValueError(f"invalid value ({msg[msg.rindex(':') + 1:]})") from exc + + +def _numbers_from_no_dots( + text: str, + typ: Type[T], + minimum: Optional[T] = None, + maximum: Optional[T] = None) -> tuple[T, ...]: + points = text.replace("...", " ... ").replace(", ", " ").split() + steps = tuple(sorted(set(_get_points(points, typ)))) + under = minimum is not None and steps[0] < minimum + over = maximum is not None and steps[-1] > maximum + if under and over: + raise ValueError(f"value must be between {minimum} and {maximum}") + if under: + raise ValueError(f"value must be at least {minimum}") + if over: + raise ValueError(f"value must be at most {maximum}") + return steps + + +def _numbers_from_dots( + text: str, + typ: Type[T], + minimum: Optional[T] = None, + maximum: Optional[T] = None, + enforce_range: Optional[bool] = True) -> tuple[T, ...]: + # many branches are results of many checks and don't degrade readability + # pylint: disable=too-many-branches + points = text.replace("...", " ... ").replace(",", " ").split() + if points.count("...") > 1: + raise ValueError("multiple '...'.") + dotind = points.index("...") + pre = _get_points(points[:dotind], typ) + post = _get_points(points[dotind + 1:], typ) + if pre and post and pre[-1] >= post[0]: + raise ValueError("values before '...' must be smaller than values after.") + + diffs = {y - x for x, y in zip(pre, pre[1:])} \ + | {y - x for x, y in zip(post, post[1:])} + if not diffs: + raise ValueError("at least two values are required before or after '...'.") + diff_of_diffs = max(diffs) - min(diffs) + if diff_of_diffs > 1e-10: + raise ValueError("points must be in uniform order.") + diff = next(iter(diffs)) + if typ is float: + diff = round(diff, 7) + if diff <= 0: + raise ValueError("points must be in increasing order.") + + minpoint = pre[0] if pre else minimum + maxpoint = post[-1] if post else maximum + if minpoint is None: + raise ValueError("minimum value is missing.") + if maxpoint is None: + raise ValueError("maximum value is missing.") + if enforce_range: + if minimum is not None and minpoint < minimum: + raise ValueError(f"minimum value is below the minimum {minimum}.") + if maximum is not None and maxpoint > maximum: + raise ValueError(f"maximum value is above the maximum {maximum}.") + steps = (maxpoint - minpoint) // diff + if (minpoint - maxpoint) % diff > 1e-10: + if pre and post: + raise ValueError( + "the sequence before '...' does not end with the sequence after it.") + if not pre: + minpoint = maxpoint - steps * diff + else: + maxpoint = minpoint + steps * diff + + if typ is int: + return tuple(range(minpoint, maxpoint + diff, diff)) + else: + points = [minpoint + i * diff + for i in range(int((maxpoint - minpoint) / diff) + 1)] + if maxpoint - points[-1] > 1e-10: + points.append(maxpoint) + return tuple(points) diff --git a/doc/visual-programming/source/index.rst b/doc/visual-programming/source/index.rst index 1ec40749ce1..e80687c34cc 100644 --- a/doc/visual-programming/source/index.rst +++ b/doc/visual-programming/source/index.rst @@ -135,6 +135,7 @@ Evaluate widgets/evaluate/rocanalysis widgets/evaluate/testandscore widgets/evaluate/permutationplot + widgets/evaluate/parameterfitter .. toctree:: diff --git a/doc/visual-programming/source/widgets/evaluate/images/ParameterFitter.png b/doc/visual-programming/source/widgets/evaluate/images/ParameterFitter.png new file mode 100644 index 00000000000..94505714fea Binary files /dev/null and b/doc/visual-programming/source/widgets/evaluate/images/ParameterFitter.png differ diff --git a/doc/visual-programming/source/widgets/evaluate/parameterfitter.md b/doc/visual-programming/source/widgets/evaluate/parameterfitter.md new file mode 100644 index 00000000000..cef2c0c6f24 --- /dev/null +++ b/doc/visual-programming/source/widgets/evaluate/parameterfitter.md @@ -0,0 +1,18 @@ +Parameter Fitter +================ + +Find the best hyper-parameters for a model. + +**Inputs** + +- Data: input data +- Learner: learning algorithm + +Parameter fitter shows performance of a learning algorithms with different settings of a hyper-parameter. The widget is currently limited to a single integer parameter. Not all learning algorithms support hyper-parameter tuning. + +![](images/ParameterFitter.png) + +1. Choose the parameter to fit. +2. Define the lower and the upper limit; step size is determined automatically. +3. Alternatively, specifies the values for the parameter. The widget also accepts `...`, e.g. `1, 2, 3, ..., 10` or `40, 60, ..., 100`. When the parameter has a minimal value (e.g. the number of components cannot be negative), one can also omit the lower bound, e.g. `..., 80, 100`; and if the parameter has a maximal value, one can omit the upper bound, e.g. `2, 4, 6, ...,`. +4. A plot showing the performance at different values of the parameter. The graph shows AUC for classification problems and R2 for regression. \ No newline at end of file diff --git a/doc/widgets.json b/doc/widgets.json index 786372a7960..ea86b3ae409 100644 --- a/doc/widgets.json +++ b/doc/widgets.json @@ -886,6 +886,13 @@ "icon": "../Orange/widgets/evaluate/icons/PermutationPlot.svg", "background": "#C3F3F3", "keywords": [] + }, + { + "text": "Parameter Fitter", + "doc": "visual-programming/source/widgets/evaluate/parameterfitter.md", + "icon": "../Orange/widgets/evaluate/icons/ParameterFitter.svg", + "background": "#C3F3F3", + "keywords": [] } ] ], diff --git a/i18n/si/msgs.jaml b/i18n/si/msgs.jaml index b9b5e38d520..e9ec4f22eaa 100644 --- a/i18n/si/msgs.jaml +++ b/i18n/si/msgs.jaml @@ -594,6 +594,10 @@ classification/random_forest.py: def `__init__`: gini: false sqrt: false + def `fitted_parameters`: + n_estimators: false + Number of trees: Število dreves + Trees: Dreves classification/rules.py: CN2Learner: false CN2UnorderedLearner: false @@ -3263,6 +3267,10 @@ regression/pls.py: def `incompatibility_reason`: Numeric targets expected.: Metoda zahteva številčne ciljne spremenljivke. Only numeric target variables expected.: Metoda deluje le za številčne ciljne spremenljivke. + def `fitted_parameters`: + n_components: false + Number of components: Število komponent + Comp: Komp __main__: false housing: false 'learner: {learner}\nRMSE: {ca}\n': false @@ -3276,6 +3284,10 @@ regression/random_forest.py: class `RandomForestRegressionLearner`: def `__init__`: squared_error: false + def `fitted_parameters`: + n_estimators: false + Number of trees: Število dreves + Trees: Dreves regression/simple_random_forest.py: SimpleRandomForestLearner: false class `SimpleRandomForestLearner`: @@ -8998,6 +9010,92 @@ widgets/evaluate/owliftcurve.py: array dimensions don't match: false mergesort: false __main__: false +widgets/evaluate/owparameterfitter.py: + def `_search`: + Calculating...: Računam... + class `ParameterSetter`: + Gridlines: Mrežne črte + Show: Pokaži + def `axis_items`: + item: false + class `FitterPlot`: + def `clear_all`: + left: false + bottom: false + def `set_data`: + bottom: false + left: false + '#6fa255': false + '#3a78b6': false + '#333': false + pen: false + width: false + symbol: false + s: false + Train: Učni + CV: Prečno prev. + def `help_event`: + : false + : false + : + : false + : false + : + : false + : false +
Train:Učni:{round(scores[0], 3)}
CV:Prečno preverjanje:{round(scores[1], 3)}
: false + def `__get_index_at`: + height: false + class `RangePreview`: + def `paintEvent`: + {self.__steps[-1]}: false + ', ': false + 'Steps: ': 'Koraki: ' + class `OWParameterFitter`: + Parameter Fitter: Umerjanje parametrov + Fit learner for various values of fitting parameter.: Umeri model za različne vrednosti parametrov. + icons/ParameterFitter.svg: false + graph.plotItem: false + class `Inputs`: + Data: Podatki + Learner: Učni algoritem + class `Error`: + {}: false + At least {N_FOLD} instances are needed.: Potrebujemo vsaj {N_FOLD} primerov. + "Invalid values for '{}': {}": Neveljavne vrednosti za '{}': {} + Minimum must be less than maximum.: Minimum mora biti manjši od maksimuma. + class `Warning`: + {} has no parameters to fit.: {} nima parametrov za umerjanje. + def `_add_controls`: + Settings: Nastavitve + parameter_index: false + type: false + Range:: Območje: + minimum: false + From:: Od: + maximum: false + To:: Do: + Manual:: Ročno: + manual_steps: false + e.g. 10, 20, ..., 50: npr. 10, 20, ..., 50 + auto_commit: false + def `_steps_from_manual`: + ...: false + ', ': false + def `_set_range_controls`: + The widget currently supports only int parameters: false + Enter a list of values: Vnesite seznam vrednosti + {tip} between {param.min} and {param.max}.: {tip} med {param.min} in {param.max}. + {tip} greater or equal to {param.min}.: {tip} večjih ali enakih {param.min}. + {tip} smaller or equal to {param.max}.: {tip} manjših ali enakih {param.max}. + def `send_report`: + Settings: Nastavitve + Parameter: Parameter + Range: Območje + ', ': false + Plot: Graf + __main__: false + housing: false widgets/evaluate/owpermutationplot.py: def `permutation`: Calculating...: Računam... @@ -13061,6 +13159,35 @@ widgets/utils/textimport.py: def `main`: rb: false __main__: false +widgets/utils/userinput.py: + T: false + def `numbers_from_list`: + (^|[^.])\.\.\.($|[^.]): false + def `_get_points`: + invalid value ({msg[msg.rindex(':') + 1:]}): nepravilna vrednost ({msg[msg.rindex(':') + 1:]}) + def `_numbers_from_no_dots`: + ...: false + ' ... ': false + ', ': false + ' ': false + value must be between {minimum} and {maximum}: vrednost mora biti med {minimum} in {maximum} + value must be at least {minimum}: vrednost mora biti vsaj {minimum} + value must be at most {maximum}: vrednost mora biti največ {maximum} + def `_numbers_from_dots`: + ...: false + ' ... ': false + ,: false + ' ': false + multiple '...'.: več '...' + values before '...' must be smaller than values after.: vrednosti pred '...' morajo biti manjše od vrednosti za '...' + at least two values are required before or after '...'.: pred ali za '...' morata biti vsaj dve vrednosti + points must be in uniform order.: razlike med vrednostmi morajo biti enake + points must be in increasing order.: vrednosti morajo biti naraščajoče + minimum value is missing.: manjka začetna vrednost + maximum value is missing.: manjka končna vrednost + minimum value is below the minimum {minimum}.: začetna vrednost je pod minimumom ({minimum}) + maximum value is above the maximum {maximum}.: končna vrednost je nad maksimumom ({maximum}) + the sequence before '...' does not end with the sequence after it.: zaporedje pred '...' se ne konča z zaporedjem za '...' widgets/utils/webview.py: WebviewWidget: false widgets/utils/widgetpreview.py: