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