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 = "
" \
+ "" \
+ "Train: " \
+ f"{round(scores[0], 3)} " \
+ " " \
+ "CV: " \
+ f"{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
+ Train: : Učni:
+ {round(scores[0], 3)} : false
+ : false
+ CV: : Prečno preverjanje:
+ {round(scores[1], 3)} : false
+ : false
+
: 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: