1import logging 2import warnings 3from collections import OrderedDict, namedtuple 4from functools import partial 5from itertools import chain 6from types import SimpleNamespace 7from typing import Any, Callable, List, Tuple 8 9import numpy as np 10from AnyQt.QtCore import ( 11 QItemSelection, QItemSelectionModel, QItemSelectionRange, Qt, 12 pyqtSignal as Signal 13) 14from AnyQt.QtGui import QFontMetrics 15from AnyQt.QtWidgets import ( 16 QButtonGroup, QCheckBox, QGridLayout, QHeaderView, QItemDelegate, 17 QRadioButton, QStackedWidget, QTableView 18) 19from orangewidget.settings import IncompatibleContext 20from scipy.sparse import issparse 21 22from Orange.data import ( 23 ContinuousVariable, DiscreteVariable, Domain, StringVariable, Table 24) 25from Orange.data.util import get_unique_names_duplicates 26from Orange.preprocess import score 27from Orange.widgets import gui, report 28from Orange.widgets.settings import ( 29 ContextSetting, DomainContextHandler, Setting 30) 31from Orange.widgets.unsupervised.owdistances import InterruptException 32from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState 33from Orange.widgets.utils.itemmodels import PyTableModel 34from Orange.widgets.utils.sql import check_sql_input 35from Orange.widgets.utils.widgetpreview import WidgetPreview 36from Orange.widgets.widget import AttributeList, Input, Msg, Output, OWWidget 37 38log = logging.getLogger(__name__) 39 40 41class ProblemType: 42 CLASSIFICATION, REGRESSION, UNSUPERVISED = range(3) 43 44 @classmethod 45 def from_variable(cls, variable): 46 return (cls.CLASSIFICATION if isinstance(variable, DiscreteVariable) else 47 cls.REGRESSION if isinstance(variable, ContinuousVariable) else 48 cls.UNSUPERVISED) 49 50ScoreMeta = namedtuple("score_meta", ["name", "shortname", "scorer", 'problem_type', 'is_default']) 51 52# Default scores. 53CLS_SCORES = [ 54 ScoreMeta("Information Gain", "Info. gain", 55 score.InfoGain, ProblemType.CLASSIFICATION, False), 56 ScoreMeta("Information Gain Ratio", "Gain ratio", 57 score.GainRatio, ProblemType.CLASSIFICATION, True), 58 ScoreMeta("Gini Decrease", "Gini", 59 score.Gini, ProblemType.CLASSIFICATION, True), 60 ScoreMeta("ANOVA", "ANOVA", 61 score.ANOVA, ProblemType.CLASSIFICATION, False), 62 ScoreMeta("χ²", "χ²", 63 score.Chi2, ProblemType.CLASSIFICATION, False), 64 ScoreMeta("ReliefF", "ReliefF", 65 score.ReliefF, ProblemType.CLASSIFICATION, False), 66 ScoreMeta("FCBF", "FCBF", 67 score.FCBF, ProblemType.CLASSIFICATION, False) 68] 69REG_SCORES = [ 70 ScoreMeta("Univariate Regression", "Univar. reg.", 71 score.UnivariateLinearRegression, ProblemType.REGRESSION, True), 72 ScoreMeta("RReliefF", "RReliefF", 73 score.RReliefF, ProblemType.REGRESSION, True) 74] 75SCORES = CLS_SCORES + REG_SCORES 76 77 78class TableView(QTableView): 79 manualSelection = Signal() 80 81 def __init__(self, parent=None, **kwargs): 82 super().__init__(parent=parent, 83 selectionBehavior=QTableView.SelectRows, 84 selectionMode=QTableView.ExtendedSelection, 85 sortingEnabled=True, 86 showGrid=True, 87 cornerButtonEnabled=False, 88 alternatingRowColors=False, 89 **kwargs) 90 self.setItemDelegate(gui.ColoredBarItemDelegate(self)) 91 self.setItemDelegateForColumn(0, QItemDelegate()) 92 93 header = self.verticalHeader() 94 header.setSectionResizeMode(header.Fixed) 95 header.setFixedWidth(50) 96 header.setDefaultSectionSize(22) 97 header.setTextElideMode(Qt.ElideMiddle) # Note: https://bugreports.qt.io/browse/QTBUG-62091 98 99 header = self.horizontalHeader() 100 header.setSectionResizeMode(header.Fixed) 101 header.setFixedHeight(24) 102 header.setDefaultSectionSize(80) 103 header.setTextElideMode(Qt.ElideMiddle) 104 105 def setVHeaderFixedWidthFromLabel(self, max_label): 106 header = self.verticalHeader() 107 width = QFontMetrics(header.font()).horizontalAdvance(max_label) 108 header.setFixedWidth(min(width + 40, 400)) 109 110 def mousePressEvent(self, event): 111 super().mousePressEvent(event) 112 self.manualSelection.emit() 113 114 115class TableModel(PyTableModel): 116 def __init__(self, *args, **kwargs): 117 super().__init__(*args, **kwargs) 118 self._extremes = {} 119 120 def data(self, index, role=Qt.DisplayRole): 121 if role == gui.BarRatioRole and index.isValid(): 122 value = super().data(index, Qt.EditRole) 123 if not isinstance(value, float): 124 return None 125 vmin, vmax = self._extremes.get(index.column(), (-np.inf, np.inf)) 126 value = (value - vmin) / ((vmax - vmin) or 1) 127 return value 128 129 if role == Qt.DisplayRole: 130 role = Qt.EditRole 131 132 value = super().data(index, role) 133 134 # Display nothing for non-existent attr value counts in the first column 135 if role == Qt.EditRole and index.column() == 0 and np.isnan(value): 136 return '' 137 138 return value 139 140 def headerData(self, section, orientation, role=Qt.DisplayRole): 141 if role == Qt.InitialSortOrderRole: 142 return Qt.DescendingOrder 143 return super().headerData(section, orientation, role) 144 145 def setExtremesFrom(self, column, values): 146 """Set extremes for columnn's ratio bars from values""" 147 try: 148 with warnings.catch_warnings(): 149 warnings.filterwarnings( 150 "ignore", ".*All-NaN slice encountered.*", RuntimeWarning) 151 vmin = np.nanmin(values) 152 if np.isnan(vmin): 153 raise TypeError 154 except TypeError: 155 vmin, vmax = -np.inf, np.inf 156 else: 157 vmax = np.nanmax(values) 158 self._extremes[column] = (vmin, vmax) 159 160 def resetSorting(self, yes_reset=False): 161 # pylint: disable=arguments-differ 162 """We don't want to invalidate our sort proxy model everytime we 163 wrap a new list. Our proxymodel only invalidates explicitly 164 (i.e. when new data is set)""" 165 if yes_reset: 166 super().resetSorting() 167 168 def _argsortData(self, data, order): 169 """Always sort NaNs last""" 170 indices = np.argsort(data, kind='mergesort') 171 if order == Qt.DescendingOrder: 172 return np.roll(indices[::-1], -np.isnan(data).sum()) 173 return indices 174 175 176class Results(SimpleNamespace): 177 method_scores: Tuple[ScoreMeta, np.ndarray] = None 178 scorer_scores: Tuple[ScoreMeta, Tuple[np.ndarray, List[str]]] = None 179 180 181def get_method_scores(data: Table, method: ScoreMeta) -> np.ndarray: 182 estimator = method.scorer() 183 # The widget handles infs and nans. 184 # Any errors in scorers need to be detected elsewhere. 185 with np.errstate(all="ignore"): 186 try: 187 scores = np.asarray(estimator(data)) 188 except ValueError: 189 try: 190 scores = np.array( 191 [estimator(data, attr) for attr in data.domain.attributes] 192 ) 193 except ValueError: 194 log.error("%s doesn't work on this data", method.name) 195 scores = np.full(len(data.domain.attributes), np.nan) 196 else: 197 log.warning( 198 "%s had to be computed separately for each " "variable", 199 method.name, 200 ) 201 return scores 202 203 204def get_scorer_scores( 205 data: Table, scorer: ScoreMeta 206) -> Tuple[np.ndarray, Tuple[str]]: 207 try: 208 scores = scorer.scorer.score_data(data).T 209 except (ValueError, TypeError): 210 log.error("%s doesn't work on this data", scorer.name) 211 scores = np.full((len(data.domain.attributes), 1), np.nan) 212 213 labels = ( 214 (scorer.shortname,) 215 if scores.shape[1] == 1 216 else tuple( 217 scorer.shortname + "_" + str(i) 218 for i in range(1, 1 + scores.shape[1]) 219 ) 220 ) 221 return scores, labels 222 223 224def run( 225 data: Table, 226 methods: List[ScoreMeta], 227 scorers: List[ScoreMeta], 228 state: TaskState, 229) -> Results: 230 progress_steps = iter(np.linspace(0, 100, len(methods) + len(scorers))) 231 232 def call_with_cb(get_scores: Callable, method: ScoreMeta): 233 scores = get_scores(data, method) 234 state.set_progress_value(next(progress_steps)) 235 if state.is_interruption_requested(): 236 raise InterruptException 237 return scores 238 239 method_scores = tuple( 240 (method, call_with_cb(get_method_scores, method)) for method in methods 241 ) 242 scorer_scores = tuple( 243 (scorer, call_with_cb(get_scorer_scores, scorer)) for scorer in scorers 244 ) 245 return Results(method_scores=method_scores, scorer_scores=scorer_scores) 246 247 248class OWRank(OWWidget, ConcurrentWidgetMixin): 249 name = "Rank" 250 description = "Rank and filter data features by their relevance." 251 icon = "icons/Rank.svg" 252 priority = 1102 253 keywords = [] 254 255 buttons_area_orientation = Qt.Vertical 256 257 class Inputs: 258 data = Input("Data", Table) 259 scorer = Input("Scorer", score.Scorer, multiple=True) 260 261 class Outputs: 262 reduced_data = Output("Reduced Data", Table, default=True) 263 scores = Output("Scores", Table) 264 features = Output("Features", AttributeList, dynamic=False) 265 266 SelectNone, SelectAll, SelectManual, SelectNBest = range(4) 267 268 nSelected = ContextSetting(5) 269 auto_apply = Setting(True) 270 271 sorting = Setting((0, Qt.DescendingOrder)) 272 selected_methods = Setting(set()) 273 274 settings_version = 3 275 settingsHandler = DomainContextHandler() 276 selected_attrs = ContextSetting([], schema_only=True) 277 selectionMethod = ContextSetting(SelectNBest) 278 279 class Information(OWWidget.Information): 280 no_target_var = Msg("Data does not have a (single) target variable.") 281 missings_imputed = Msg('Missing values will be imputed as needed.') 282 283 class Error(OWWidget.Error): 284 invalid_type = Msg("Cannot handle target variable type {}") 285 inadequate_learner = Msg("Scorer {} inadequate: {}") 286 no_attributes = Msg("Data does not have a single attribute.") 287 288 class Warning(OWWidget.Warning): 289 renamed_variables = Msg( 290 "Variables with duplicated names have been renamed.") 291 292 def __init__(self): 293 OWWidget.__init__(self) 294 ConcurrentWidgetMixin.__init__(self) 295 self.scorers = OrderedDict() 296 self.out_domain_desc = None 297 self.data = None 298 self.problem_type_mode = ProblemType.CLASSIFICATION 299 300 # results caches 301 self.scorers_results = {} 302 self.methods_results = {} 303 304 if not self.selected_methods: 305 self.selected_methods = {method.name for method in SCORES 306 if method.is_default} 307 308 # GUI 309 self.ranksModel = model = TableModel(parent=self) # type: TableModel 310 self.ranksView = view = TableView(self) # type: TableView 311 self.mainArea.layout().addWidget(view) 312 view.setModel(model) 313 view.setColumnWidth(0, 30) 314 view.selectionModel().selectionChanged.connect(self.on_select) 315 316 def _set_select_manual(): 317 self.setSelectionMethod(OWRank.SelectManual) 318 319 view.manualSelection.connect(_set_select_manual) 320 view.verticalHeader().sectionClicked.connect(_set_select_manual) 321 view.horizontalHeader().sectionClicked.connect(self.headerClick) 322 323 self.measuresStack = stacked = QStackedWidget(self) 324 self.controlArea.layout().addWidget(stacked) 325 326 for scoring_methods in (CLS_SCORES, 327 REG_SCORES, 328 []): 329 box = gui.vBox(None, "Scoring Methods" if scoring_methods else None) 330 stacked.addWidget(box) 331 for method in scoring_methods: 332 box.layout().addWidget(QCheckBox( 333 method.name, self, 334 objectName=method.shortname, # To be easily found in tests 335 checked=method.name in self.selected_methods, 336 stateChanged=partial(self.methodSelectionChanged, method_name=method.name))) 337 gui.rubber(box) 338 339 gui.rubber(self.controlArea) 340 341 self.switchProblemType(ProblemType.CLASSIFICATION) 342 343 selMethBox = gui.vBox(self.buttonsArea, "Select Attributes") 344 345 grid = QGridLayout() 346 grid.setContentsMargins(0, 0, 0, 0) 347 grid.setSpacing(6) 348 self.selectButtons = QButtonGroup() 349 self.selectButtons.buttonClicked[int].connect(self.setSelectionMethod) 350 351 def button(text, buttonid, toolTip=None): 352 b = QRadioButton(text) 353 self.selectButtons.addButton(b, buttonid) 354 if toolTip is not None: 355 b.setToolTip(toolTip) 356 return b 357 358 b1 = button(self.tr("None"), OWRank.SelectNone) 359 b2 = button(self.tr("All"), OWRank.SelectAll) 360 b3 = button(self.tr("Manual"), OWRank.SelectManual) 361 b4 = button(self.tr("Best ranked:"), OWRank.SelectNBest) 362 363 s = gui.spin(selMethBox, self, "nSelected", 1, 999, 364 callback=lambda: self.setSelectionMethod(OWRank.SelectNBest), 365 addToLayout=False) 366 367 grid.addWidget(b1, 0, 0) 368 grid.addWidget(b2, 1, 0) 369 grid.addWidget(b3, 2, 0) 370 grid.addWidget(b4, 3, 0) 371 grid.addWidget(s, 3, 1) 372 373 self.selectButtons.button(self.selectionMethod).setChecked(True) 374 375 selMethBox.layout().addLayout(grid) 376 377 gui.auto_send(self.buttonsArea, self, "auto_apply") 378 379 self.resize(690, 500) 380 381 def switchProblemType(self, index): 382 """ 383 Switch between discrete/continuous/no_class mode 384 """ 385 self.measuresStack.setCurrentIndex(index) 386 self.problem_type_mode = index 387 388 @Inputs.data 389 @check_sql_input 390 def set_data(self, data): 391 self.closeContext() 392 self.selected_attrs = [] 393 self.ranksModel.clear() 394 self.ranksModel.resetSorting(True) 395 396 self.scorers_results = {} 397 self.methods_results = {} 398 self.cancel() 399 400 self.Error.clear() 401 self.Information.clear() 402 self.Information.missings_imputed( 403 shown=data is not None and data.has_missing()) 404 405 if data is not None and not data.domain.attributes: 406 data = None 407 self.Error.no_attributes() 408 self.data = data 409 self.switchProblemType(ProblemType.CLASSIFICATION) 410 if self.data is not None: 411 domain = self.data.domain 412 if domain.has_discrete_class: 413 problem_type = ProblemType.CLASSIFICATION 414 elif domain.has_continuous_class: 415 problem_type = ProblemType.REGRESSION 416 elif not domain.class_var: 417 self.Information.no_target_var() 418 problem_type = ProblemType.UNSUPERVISED 419 else: 420 # This can happen? 421 self.Error.invalid_type(type(domain.class_var).__name__) 422 problem_type = None 423 424 if problem_type is not None: 425 self.switchProblemType(problem_type) 426 427 self.ranksModel.setVerticalHeaderLabels(domain.attributes) 428 self.ranksView.setVHeaderFixedWidthFromLabel( 429 max((a.name for a in domain.attributes), key=len)) 430 431 self.selectionMethod = OWRank.SelectNBest 432 433 self.openContext(data) 434 self.selectButtons.button(self.selectionMethod).setChecked(True) 435 436 def handleNewSignals(self): 437 self.setStatusMessage('Running') 438 self.update_scores() 439 self.setStatusMessage('') 440 self.on_select() 441 442 @Inputs.scorer 443 def set_learner(self, scorer, id): # pylint: disable=redefined-builtin 444 if scorer is None: 445 self.scorers.pop(id, None) 446 else: 447 # Avoid caching a (possibly stale) previous instance of the same 448 # Scorer passed via the same signal 449 if id in self.scorers: 450 self.scorers_results = {} 451 452 self.scorers[id] = ScoreMeta(scorer.name, scorer.name, scorer, 453 ProblemType.from_variable(scorer.class_type), 454 False) 455 456 def _get_methods(self): 457 return [ 458 method 459 for method in SCORES 460 if ( 461 method.name in self.selected_methods 462 and method.problem_type == self.problem_type_mode 463 and ( 464 not issparse(self.data.X) 465 or method.scorer.supports_sparse_data 466 ) 467 ) 468 ] 469 470 def _get_scorers(self): 471 scorers = [] 472 for scorer in self.scorers.values(): 473 if scorer.problem_type in ( 474 self.problem_type_mode, 475 ProblemType.UNSUPERVISED, 476 ): 477 scorers.append(scorer) 478 else: 479 self.Error.inadequate_learner( 480 scorer.name, scorer.learner_adequacy_err_msg 481 ) 482 return scorers 483 484 def update_scores(self): 485 if self.data is None: 486 self.ranksModel.clear() 487 self.Outputs.scores.send(None) 488 return 489 490 self.Error.inadequate_learner.clear() 491 492 scorers = [ 493 s for s in self._get_scorers() if s not in self.scorers_results 494 ] 495 methods = [ 496 m for m in self._get_methods() if m not in self.methods_results 497 ] 498 self.start(run, self.data, methods, scorers) 499 500 def on_done(self, result: Results) -> None: 501 self.methods_results.update(result.method_scores) 502 self.scorers_results.update(result.scorer_scores) 503 504 methods = self._get_methods() 505 method_labels = tuple(m.shortname for m in methods) 506 method_scores = tuple(self.methods_results[m] for m in methods) 507 508 scores = [self.scorers_results[s] for s in self._get_scorers()] 509 scorer_scores, scorer_labels = zip(*scores) if scores else ((), ()) 510 511 labels = method_labels + tuple(chain.from_iterable(scorer_labels)) 512 model_array = np.column_stack( 513 ( 514 [len(a.values) if a.is_discrete else np.nan 515 for a in self.data.domain.attributes], 516 ) 517 + method_scores 518 + scorer_scores 519 ) 520 for column, values in enumerate(model_array.T): 521 self.ranksModel.setExtremesFrom(column, values) 522 523 self.ranksModel.wrap(model_array.tolist()) 524 self.ranksModel.setHorizontalHeaderLabels(('#',) + labels) 525 self.ranksView.setColumnWidth(0, 40) 526 527 # Re-apply sort 528 try: 529 sort_column, sort_order = self.sorting 530 if sort_column < len(labels): 531 # adds 1 for '#' (discrete count) column 532 self.ranksModel.sort(sort_column + 1, sort_order) 533 self.ranksView.horizontalHeader().setSortIndicator( 534 sort_column + 1, sort_order 535 ) 536 except ValueError: 537 pass 538 539 self.autoSelection() 540 self.Outputs.scores.send(self.create_scores_table(labels)) 541 542 def on_exception(self, ex: Exception) -> None: 543 raise ex 544 545 def on_partial_result(self, result: Any) -> None: 546 pass 547 548 def on_select(self): 549 # Save indices of attributes in the original, unsorted domain 550 selected_rows = self.ranksView.selectionModel().selectedRows(0) 551 row_indices = [i.row() for i in selected_rows] 552 attr_indices = self.ranksModel.mapToSourceRows(row_indices) 553 self.selected_attrs = [self.data.domain[idx] for idx in attr_indices] 554 self.commit() 555 556 def setSelectionMethod(self, method): 557 self.selectionMethod = method 558 self.selectButtons.button(method).setChecked(True) 559 self.autoSelection() 560 561 def autoSelection(self): 562 selModel = self.ranksView.selectionModel() 563 model = self.ranksModel 564 rowCount = model.rowCount() 565 columnCount = model.columnCount() 566 567 if self.selectionMethod == OWRank.SelectNone: 568 selection = QItemSelection() 569 elif self.selectionMethod == OWRank.SelectAll: 570 selection = QItemSelection( 571 model.index(0, 0), 572 model.index(rowCount - 1, columnCount - 1) 573 ) 574 elif self.selectionMethod == OWRank.SelectNBest: 575 nSelected = min(self.nSelected, rowCount) 576 selection = QItemSelection( 577 model.index(0, 0), 578 model.index(nSelected - 1, columnCount - 1) 579 ) 580 else: 581 selection = QItemSelection() 582 if self.selected_attrs is not None: 583 attr_indices = [self.data.domain.attributes.index(var) 584 for var in self.selected_attrs] 585 for row in model.mapFromSourceRows(attr_indices): 586 selection.append(QItemSelectionRange( 587 model.index(row, 0), model.index(row, columnCount - 1))) 588 589 selModel.select(selection, QItemSelectionModel.ClearAndSelect) 590 591 def headerClick(self, index): 592 if index >= 1 and self.selectionMethod == OWRank.SelectNBest: 593 # Reselect the top ranked attributes 594 self.autoSelection() 595 596 # Store the header states 597 sort_order = self.ranksModel.sortOrder() 598 sort_column = self.ranksModel.sortColumn() - 1 # -1 for '#' (discrete count) column 599 self.sorting = (sort_column, sort_order) 600 601 def methodSelectionChanged(self, state, method_name): 602 if state == Qt.Checked: 603 self.selected_methods.add(method_name) 604 elif method_name in self.selected_methods: 605 self.selected_methods.remove(method_name) 606 607 self.update_scores() 608 609 def send_report(self): 610 if not self.data: 611 return 612 self.report_domain("Input", self.data.domain) 613 self.report_table("Ranks", self.ranksView, num_format="{:.3f}") 614 if self.out_domain_desc is not None: 615 self.report_items("Output", self.out_domain_desc) 616 617 def commit(self): 618 if not self.selected_attrs: 619 self.Outputs.reduced_data.send(None) 620 self.Outputs.features.send(None) 621 self.out_domain_desc = None 622 else: 623 reduced_domain = Domain( 624 self.selected_attrs, self.data.domain.class_var, 625 self.data.domain.metas) 626 data = self.data.transform(reduced_domain) 627 self.Outputs.reduced_data.send(data) 628 self.Outputs.features.send(AttributeList(self.selected_attrs)) 629 self.out_domain_desc = report.describe_domain(data.domain) 630 631 def create_scores_table(self, labels): 632 self.Warning.renamed_variables.clear() 633 model_list = self.ranksModel.tolist() 634 if not model_list or len(model_list[0]) == 1: # Empty or just n_values column 635 return None 636 unique, renamed = get_unique_names_duplicates(labels + ('Feature',), 637 return_duplicated=True) 638 if renamed: 639 self.Warning.renamed_variables(', '.join(renamed)) 640 641 domain = Domain([ContinuousVariable(label) for label in unique[:-1]], 642 metas=[StringVariable(unique[-1])]) 643 644 # Prevent np.inf scores 645 finfo = np.finfo(np.float64) 646 scores = np.clip(np.array(model_list)[:, 1:], finfo.min, finfo.max) 647 648 feature_names = np.array([a.name for a in self.data.domain.attributes]) 649 # Reshape to 2d array as Table does not like 1d arrays 650 feature_names = feature_names[:, None] 651 652 new_table = Table(domain, scores, metas=feature_names) 653 new_table.name = "Feature Scores" 654 return new_table 655 656 @classmethod 657 def migrate_settings(cls, settings, version): 658 # If older settings, restore sort header to default 659 # Saved selected_rows will likely be incorrect 660 if version is None or version < 2: 661 column, order = 0, Qt.DescendingOrder 662 headerState = settings.pop("headerState", None) 663 664 # Lacking knowledge of last problemType, use discrete ranks view's ordering 665 if isinstance(headerState, (tuple, list)): 666 headerState = headerState[0] 667 668 if isinstance(headerState, bytes): 669 hview = QHeaderView(Qt.Horizontal) 670 hview.restoreState(headerState) 671 column, order = hview.sortIndicatorSection() - 1, hview.sortIndicatorOrder() 672 settings["sorting"] = (column, order) 673 674 @classmethod 675 def migrate_context(cls, context, version): 676 if version is None or version < 3: 677 # Selections were stored as indices, so these contexts matched 678 # any domain. The only safe thing to do is to remove them. 679 raise IncompatibleContext 680 681 682if __name__ == "__main__": # pragma: no cover 683 from Orange.classification import RandomForestLearner 684 previewer = WidgetPreview(OWRank) 685 previewer.run(Table("heart_disease.tab"), no_exit=True) 686 previewer.send_signals( 687 set_learner=(RandomForestLearner(), (3, 'Learner', None))) 688 previewer.run() 689 690 # pylint: disable=pointless-string-statement 691 """ 692 WidgetPreview(OWRank).run( 693 set_learner=(RandomForestLearner(), (3, 'Learner', None)), 694 set_data=Table("heart_disease.tab")) 695 """ 696