Skip to content

Commit 2c326be

Browse files
committed
Parameter Fitter: Check ranges for manual edit
1 parent e5b7ace commit 2c326be

File tree

2 files changed

+160
-30
lines changed

2 files changed

+160
-30
lines changed

Orange/widgets/evaluate/owparameterfitter.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pyqtgraph as pg
1111

12+
from orangewidget.utils.itemmodels import signal_blocking
1213
from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog, \
1314
KeyType, ValueType
1415

@@ -331,7 +332,10 @@ class Error(OWWidget.Error):
331332
unknown_err = Msg("{}")
332333
not_enough_data = Msg(f"At least {N_FOLD} instances are needed.")
333334
incompatible_learner = Msg("{}")
334-
manual_steps_error = Msg("Invalid list of steps for {}:\n{}")
335+
manual_steps_error = Msg("Invalid list of values for {}:\n{}")
336+
manual_invalid_minmax = Msg("Value for {} must be between {} and {}")
337+
manual_invalid_minimum = Msg("Value for {} must be at least {}")
338+
manual_invalid_maximum = Msg("Value for {} must be at most {}")
335339
min_max_error = Msg("Minimum must be less than maximum.")
336340

337341
class Warning(OWWidget.Warning):
@@ -398,14 +402,14 @@ def _add_controls(self):
398402

399403
gui.appendRadioButton(buttons, "Manual:")
400404
layout.addWidget(buttons, 4, 0)
401-
edit = gui.lineEdit(None, self, "manual_steps",
405+
self.edit = gui.lineEdit(None, self, "manual_steps",
402406
placeholderText="10, 20, 30",
403407
callback=self.__on_manual_changed)
404-
layout.addWidget(edit, 4, 1)
408+
layout.addWidget(self.edit, 4, 1)
405409

406410
# gui.lineEdit's connect does not call the callback on return pressed
407411
# if the line hasn't changed.
408-
@edit.returnPressed.connect
412+
@self.edit.returnPressed.connect
409413
def _():
410414
if self.type != self.MANUAL:
411415
self.type = self.MANUAL
@@ -459,6 +463,10 @@ def initial_parameters(self) -> dict:
459463
def steps(self) -> tuple[int]:
460464
self.Error.min_max_error.clear()
461465
self.Error.manual_steps_error.clear()
466+
self.Error.manual_invalid_minimum.clear()
467+
self.Error.manual_invalid_maximum.clear()
468+
self.Error.manual_invalid_minmax.clear()
469+
462470
if self.type == self.FROM_RANGE:
463471
return self._steps_from_range()
464472
else:
@@ -480,20 +488,33 @@ def _steps_from_range(self) -> tuple[int]:
480488
*range((self.minimum // step + 1) * step, self.maximum, step),
481489
self.maximum)
482490

483-
def _steps_from_manual(self) -> tuple[int]:
491+
def _steps_from_manual(self) -> tuple[int, ...]:
492+
param = self.fitted_parameters[self.parameter_index]
484493
steps = self.manual_steps
485494
if not steps:
486495
return ()
487496
try:
488-
steps = tuple(sorted(map(int, steps.split(","))))
489-
self.manual_steps = ", ".join(map(str, steps))
490-
return steps
497+
steps = tuple(sorted(set(map(int, steps.split(",")))))
491498
except ValueError as exc:
492-
self.Error.manual_steps_error(
493-
self.fitted_parameters[self.parameter_index].label,
494-
exc)
499+
self.Error.manual_steps_error(param.name, exc)
500+
return ()
501+
502+
self.manual_steps = ", ".join(map(str, steps))
503+
504+
under = param.min is not None and steps[0] < param.min
505+
over = param.max is not None and steps[-1] > param.max
506+
if under or over:
507+
if under and over:
508+
self.Error.manual_invalid_minmax(param.name, param.min, param.max)
509+
elif under:
510+
self.Error.manual_invalid_minimum(param.name, param.min)
511+
else:
512+
assert over
513+
self.Error.manual_invalid_maximum(param.name, param.max)
495514
return ()
496515

516+
return steps
517+
497518
@Inputs.data
498519
@check_multiple_targets_input
499520
def set_data(self, data: Optional[Table]):
@@ -548,22 +569,35 @@ def _set_range_controls(self):
548569
assert param.type == int, \
549570
"The widget currently supports only int parameters"
550571

572+
# Block signals to avoid changing `self.type`
573+
with signal_blocking(self.__spin_min), signal_blocking(self.__spin_max):
574+
if param.min is not None:
575+
self.__spin_min.setMinimum(param.min)
576+
self.__spin_max.setMinimum(param.min)
577+
self.minimum = param.min
578+
else:
579+
self.__spin_min.setMinimum(-MIN_MAX_SPIN)
580+
self.__spin_max.setMinimum(-MIN_MAX_SPIN)
581+
self.minimum = self.initial_parameters[param.name]
582+
if param.max is not None:
583+
self.__spin_min.setMaximum(param.max)
584+
self.__spin_max.setMaximum(param.max)
585+
self.maximum = param.max
586+
else:
587+
self.__spin_min.setMaximum(MIN_MAX_SPIN)
588+
self.__spin_max.setMaximum(MIN_MAX_SPIN)
589+
self.maximum = self.initial_parameters[param.name]
590+
591+
tip = f"Enter a list of comma-separated values"
551592
if param.min is not None:
552-
self.__spin_min.setMinimum(param.min)
553-
self.__spin_max.setMinimum(param.min)
554-
self.minimum = param.min
555-
else:
556-
self.__spin_min.setMinimum(-MIN_MAX_SPIN)
557-
self.__spin_max.setMinimum(-MIN_MAX_SPIN)
558-
self.minimum = self.initial_parameters[param.name]
559-
if param.max is not None:
560-
self.__spin_min.setMaximum(param.max)
561-
self.__spin_max.setMaximum(param.max)
562-
self.maximum = param.max
593+
if param.max is not None:
594+
self.edit.setToolTip(f"{tip} between {param.min} and {param.max}.")
595+
else:
596+
self.edit.setToolTip(f"{tip} greater or equal to {param.min}.")
597+
elif param.max is not None:
598+
self.edit.setToolTip(f"{tip} smaller or equal to {param.max}.")
563599
else:
564-
self.__spin_min.setMaximum(MIN_MAX_SPIN)
565-
self.__spin_max.setMaximum(MIN_MAX_SPIN)
566-
self.maximum = self.initial_parameters[param.name]
600+
self.edit.setToolTip("")
567601

568602
def _update_preview(self):
569603
if self.type == self.FROM_RANGE:

Orange/widgets/evaluate/tests/test_owparameterfitter.py

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
class DummyLearner(PLSRegressionLearner):
2222
def fitted_parameters(self):
2323
return [
24-
self.FittedParameter("n_components", "Foo", "foo", int, 1, None),
25-
self.FittedParameter("n_components", "Bar", "bar", int, 1, 10),
24+
self.FittedParameter("n_components", "Foo", "foo", int, 5, None),
25+
self.FittedParameter("n_components", "Bar", "bar", int, 5, 10),
2626
self.FittedParameter("n_components", "Baz", "baz", int, None, 10),
2727
self.FittedParameter("n_components", "Qux", "qux", int, None, None)
2828
]
@@ -134,6 +134,82 @@ def test_manual_steps(self):
134134
x = self.widget.graph._FitterPlot__bar_item_cv.opts["x"]
135135
self.assertEqual(list(x), [0.2, 1.2, 2.2])
136136

137+
def test_manual_steps_limits(self):
138+
w = self.widget
139+
140+
def test_errors(act):
141+
err = w.Error
142+
for error in (err.manual_steps_error, err.manual_invalid_minmax,
143+
err.manual_invalid_minimum, err.manual_invalid_maximum):
144+
self.assertIs(error.is_shown(), error is act)
145+
146+
def enter(text):
147+
w.controls.manual_steps.setText(text)
148+
w.controls.manual_steps.returnPressed.emit()
149+
150+
self.send_signal(w.Inputs.data, self._housing)
151+
self.send_signal(w.Inputs.learner, self._dummy)
152+
self.wait_until_finished()
153+
154+
# 5 to None
155+
simulate.combobox_activate_index(w.controls.parameter_index, 0)
156+
self.wait_until_finished()
157+
158+
enter("6, 9, 7")
159+
self.assertEqual(w.steps, (6, 7, 9))
160+
test_errors(None)
161+
162+
enter("6, 9, 7, 3")
163+
self.assertEqual(w.steps, ())
164+
test_errors(w.Error.manual_invalid_minimum)
165+
166+
enter("6, 9, 7")
167+
self.assertEqual(w.steps, (6, 7, 9))
168+
test_errors(None)
169+
170+
enter("6, 9, 7, 3")
171+
172+
# None to 10
173+
simulate.combobox_activate_index(w.controls.parameter_index, 2)
174+
self.wait_until_finished()
175+
176+
test_errors(None)
177+
178+
enter("12, 1, 3, -5")
179+
self.assertEqual(w.steps, ())
180+
test_errors(w.Error.manual_invalid_maximum)
181+
182+
enter("1, 3, -5")
183+
self.assertEqual(w.steps, (-5, 1, 3))
184+
test_errors(None)
185+
186+
enter("12, 1, 3, -5")
187+
188+
# No limits
189+
simulate.combobox_activate_index(w.controls.parameter_index, 3)
190+
self.wait_until_finished()
191+
self.assertEqual(w.steps, (-5, 1, 3, 12))
192+
test_errors(None)
193+
194+
# 5 to 10
195+
simulate.combobox_activate_index(w.controls.parameter_index, 1)
196+
self.wait_until_finished()
197+
198+
self.assertEqual(w.steps, ())
199+
test_errors(w.Error.manual_invalid_minmax)
200+
201+
enter("12, 8, 7, 5")
202+
self.assertEqual(w.steps, ())
203+
test_errors(w.Error.manual_invalid_maximum)
204+
205+
enter("8, 7, -5")
206+
self.assertEqual(w.steps, ())
207+
test_errors(w.Error.manual_invalid_minimum)
208+
209+
enter("8, 7, 5")
210+
self.assertEqual(w.steps, (5, 7, 8))
211+
test_errors(None)
212+
137213
def test_steps_preview(self):
138214
self.send_signal(self.widget.Inputs.data, self._housing)
139215
self.send_signal(self.widget.Inputs.learner, self._pls)
@@ -353,8 +429,10 @@ def test_steps_from_range_error(self):
353429

354430
def test_steps_from_manual_error(self):
355431
w: OWParameterFitter = self.widget
356-
self.send_signal(w.Inputs.data, self._heart)
432+
self.send_signal(w.Inputs.data, self._housing)
357433
self.send_signal(w.Inputs.learner, self._dummy)
434+
self.wait_until_finished()
435+
simulate.combobox_activate_index(w.controls.parameter_index, 3)
358436
w.type = w.MANUAL
359437

360438
w.manual_steps = "1, 2, 3, asdf, 4, 5"
@@ -374,8 +452,10 @@ def test_steps_from_manual_error(self):
374452

375453
def test_steps_from_manual(self):
376454
w: OWParameterFitter = self.widget
377-
self.send_signal(w.Inputs.data, self._heart)
455+
self.send_signal(w.Inputs.data, self._housing)
378456
self.send_signal(w.Inputs.learner, self._dummy)
457+
self.wait_until_finished()
458+
simulate.combobox_activate_index(w.controls.parameter_index, 3)
379459
w.type = w.MANUAL
380460

381461
w.manual_steps = "1, 2, 3, 4, 5"
@@ -384,10 +464,26 @@ def test_steps_from_manual(self):
384464
w.manual_steps = "1, 2, 3, 4, 5, 6"
385465
self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6))
386466

387-
# TODO: We may or may not want this.
388467
w.manual_steps = "1, 2, 10, 3, 4, 123, 5, 6"
389468
self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6, 10, 123))
390469

470+
def test_manual_tooltip(self):
471+
w: OWParameterFitter = self.widget
472+
self.send_signal(w.Inputs.data, self._housing)
473+
self.send_signal(w.Inputs.learner, self._dummy)
474+
self.wait_until_finished()
475+
476+
simulate.combobox_activate_index(w.controls.parameter_index, 0)
477+
self.assertIn("greater or equal to 5", w.edit.toolTip())
478+
479+
simulate.combobox_activate_index(w.controls.parameter_index, 1)
480+
self.assertIn("between 5 and 10", w.edit.toolTip())
481+
482+
simulate.combobox_activate_index(w.controls.parameter_index, 2)
483+
self.assertIn("smaller or equal to 10", w.edit.toolTip())
484+
485+
simulate.combobox_activate_index(w.controls.parameter_index, 3)
486+
self.assertEqual("", w.edit.toolTip())
391487

392488
if __name__ == "__main__":
393489
unittest.main()

0 commit comments

Comments
 (0)