diff --git a/orangecontrib/educational/widgets/owpolynomialclassification.py b/orangecontrib/educational/widgets/owpolynomialclassification.py index 16ca808..717499c 100644 --- a/orangecontrib/educational/widgets/owpolynomialclassification.py +++ b/orangecontrib/educational/widgets/owpolynomialclassification.py @@ -1,79 +1,79 @@ -from Orange.widgets.utils import itemmodels -from math import isnan -from os import path import copy +from colorsys import rgb_to_hsv, hsv_to_rgb +from xml.sax.saxutils import escape +from itertools import chain import numpy as np -import scipy.sparse as sp from scipy.interpolate import splprep, splev from scipy.ndimage.filters import gaussian_filter -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QPixmap, QColor, QIcon +from AnyQt.QtCore import Qt, QRectF, QObject, QPointF, QEvent +from AnyQt.QtGui import QPalette, QPen, QFont, QCursor, QColor, QBrush +from AnyQt.QtWidgets import QGraphicsSceneMouseEvent, QGraphicsTextItem -from Orange.base import Learner as InputLearner -from Orange.data import ( - ContinuousVariable, Table, Domain, StringVariable, DiscreteVariable) -from Orange.widgets import settings, gui +import pyqtgraph as pg + +from Orange.data import \ + Table, Domain, ContinuousVariable, StringVariable, DiscreteVariable +from Orange.base import Learner +from Orange.classification import \ + LogisticRegressionLearner, RandomForestLearner, TreeLearner +from Orange.preprocess.transformation import Indicator + +from Orange.widgets import gui +from Orange.widgets.settings import \ + DomainContextHandler, Setting, SettingProvider, ContextSetting +from Orange.widgets.utils.colorpalettes import DiscretePalette +from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.owlearnerwidget import OWBaseLearner -from Orange.classification import ( - LogisticRegressionLearner, - RandomForestLearner, - TreeLearner -) +from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase +from Orange.widgets.visualize.utils.plotutils import SymbolItemSample from Orange.widgets.widget import Msg, Input, Output -from orangewidget.report import report - from orangecontrib.educational.widgets.utils.polynomialtransform \ import PolynomialTransform -from orangecontrib.educational.widgets.utils.color_transform \ - import rgb_hash_brighter, rgb_to_hex from orangecontrib.educational.widgets.utils.contour import Contour -from orangecontrib.educational.widgets.highcharts import Highchart +GRID_SIZE = 60 -class Scatterplot(Highchart): - """ - Scatterplot extends Highchart and just defines some defaults: - * disable scroll-wheel zooming, - * disable all points selection - * set cursor for series to move - * adds javascript for contour - """ - def __init__(self, **kwargs): - with open(path.join(path.dirname(__file__), 'resources', 'highcharts-contour.js'), - encoding='utf-8') as f: - contour_js = f.read() +class HoverEventDelegate(QObject): + def __init__(self, delegate, parent=None): + super().__init__(parent) + self.delegate = delegate - super().__init__(enable_zoom=False, - enable_select='', - javascript=contour_js, - **kwargs) + def eventFilter(self, obj, event): + return isinstance(event, QGraphicsSceneMouseEvent) \ + and self.delegate(event) - def remove_contours(self): - self.evalJS(""" - for(i=chart.series.length - 1; i >= 0; i--){ - if(chart.series[i].type == "spline") - { - chart.series[i].remove(false); - } - }""") - def add_series(self, series): - for i, s in enumerate(series): - self.exposeObject('series%d' % i, series[i]) - self.evalJS("chart.addSeries(series%d, false);" % i) +class PolynomialPlot(OWScatterPlotBase): + # Like OWScatterPlotBase, but with reversed legend, so the target class + # is first and "others" is seecond. + @staticmethod + def _make_pen(color, width): + p = QPen(color, width) + p.setCosmetic(True) + return p - def redraw_series(self): - self.evalJS("chart.redraw();") + def _update_colored_legend(self, legend, labels, _): + if self.scatterplot_item is None or not self.palette: + return + colors = self.palette.values_to_colors(np.arange(len(labels))) + for color, label in reversed(list(zip(colors, labels))): + color = QColor(*color) + pen = self._make_pen(color.darker(self.DarkerValue), 1.5) + color.setAlpha(self.alpha_value) + brush = QBrush(color) + legend.addItem( + SymbolItemSample(pen=pen, brush=brush, size=10, symbol="o"), + escape(label)) class OWPolynomialClassification(OWBaseLearner): name = "Polynomial Classification" - description = "Widget that demonstrates classification in two classes " \ - "with polynomial expansion of attributes." + description = "Widget that demonstrates classification " \ + "with polynomial expansion of variables." keywords = ["polynomial classification", "classification", "class", "classification visualization", "polynomial features"] icon = "icons/polynomialclassification.svg" @@ -81,428 +81,389 @@ class OWPolynomialClassification(OWBaseLearner): resizing_enabled = True priority = 600 - # inputs and outputs class Inputs(OWBaseLearner.Inputs): - learner = Input("Learner", InputLearner) + learner = Input("Learner", Learner) class Outputs(OWBaseLearner.Outputs): coefficients = Output("Coefficients", Table, default=True) data = Output("Data", Table) - # data attributes - data = None - selected_data = None - probabilities_grid = None - xv = None - yv = None - - # learners LEARNER = LogisticRegressionLearner - learner_other = None - default_preprocessor = PolynomialTransform - - learner_name = settings.Setting("Polynomial Classification") - - # widget properties - attr_x = settings.Setting('') - attr_y = settings.Setting('') - target_class = settings.Setting('') - degree = settings.Setting(1) - legend_enabled = settings.Setting(True) - contours_enabled = settings.Setting(False) - contour_step = settings.Setting(0.1) - - graph_name = 'scatter' - - # settings - grid_size = 25 - contour_color = "#1f1f1f" - - # layout elements - options_box = None - cbx = None - cby = None - degree_spin = None - plot_properties_box = None - contours_enabled_checkbox = None - legend_enabled_checkbox = None - contour_step_slider = None - scatter = None - target_class_combobox = None - x_var_model = None - y_var_model = None + + settingsHandler = DomainContextHandler( + match_values=DomainContextHandler.MATCH_VALUES_CLASS) + graph = SettingProvider(PolynomialPlot) + + learner_name = Setting("Polynomial Classification") + attr_x = ContextSetting(None) + attr_y = ContextSetting(None) + target_class = Setting("") + degree = Setting(1) + contours_enabled = Setting(True) + + graph_name = 'graph' class Error(OWBaseLearner.Error): - to_few_features = Msg( - "Polynomial classification requires at least two numeric features") - no_class = Msg("Data must have a single discrete class attribute") - all_none_data = Msg("One of the features has no defined values") - no_classifier = Msg("Learner must be a classifier") + num_features = Msg("Data must contain at least two numeric variables.") + no_class = Msg("Data must have a single target attribute.") + no_nonnan_data = Msg("No points with defined values.") + same_variable = Msg("Select two different variables.") + + def __init__(self, *args, **kwargs): + # Some attributes must be created before super().__init__ + self._add_graph() + self.var_model = DomainModel(valid_types=(ContinuousVariable, )) + + super().__init__(*args, **kwargs) + self.input_learner = None + self.data = None + + self.learner = None + self.selected_data = None + self.probabilities_grid = None + self.xv = None + self.yv = None + self.contours = [] + self.init_learner() def add_main_layout(self): - # var models - self.x_var_model = itemmodels.VariableListModel() - self.y_var_model = itemmodels.VariableListModel() - - # options box - self.options_box = gui.widgetBox(self.controlArea, "Options") - opts = dict( - widget=self.options_box, master=self, orientation=Qt.Horizontal) - opts_combo = dict(opts, **dict(sendSelectedValue=True)) - self.cbx = gui.comboBox( - value='attr_x', label='X: ', callback=self.apply, **opts_combo) - self.cby = gui.comboBox( - value='attr_y', label='Y: ', callback=self.apply, **opts_combo) - self.target_class_combobox = gui.comboBox( - value='target_class', label='Target: ', - callback=self.apply, **opts_combo) - self.degree_spin = gui.spin( - value='degree', label='Polynomial expansion:', - minv=1, maxv=5, step=1, callback=self.init_learner, - alignment=Qt.AlignRight, controlWidth=70, **opts) - - self.cbx.setModel(self.x_var_model) - self.cby.setModel(self.y_var_model) - - # plot properties box - self.plot_properties_box = gui.widgetBox( - self.controlArea, "Plot Properties") - self.legend_enabled_checkbox = gui.checkBox( - self.plot_properties_box, self, 'legend_enabled', - label="Show legend", callback=self.replot) - self.contours_enabled_checkbox = gui.checkBox( - self.plot_properties_box, self, 'contours_enabled', - label="Show contours", callback=self.plot_contour) - self.contour_step_slider = gui.spin( - self.plot_properties_box, self, 'contour_step', - minv=0.10, maxv=0.50, step=0.05, callback=self.plot_contour, - label='Contour step:', decimals=2, spinType=float, - alignment=Qt.AlignRight, controlWidth=70) + box = gui.widgetBox(self.controlArea, "Variables") + gui.comboBox( + box, self, "attr_x", model=self.var_model, + callback=self._on_attr_changed) + gui.comboBox( + box, self, "attr_y", model=self.var_model, + callback=self._on_attr_changed) + gui.spin( + box, self, value='degree', label='Polynomial expansion:', + minv=1, maxv=5, step=1, alignment=Qt.AlignRight, controlWidth=70, + callback=self._on_degree_changed) + gui.comboBox( + box, self, 'target_class', label='Target:', + orientation=Qt.Horizontal, sendSelectedValue=True, + callback=self._on_target_changed) + + box = gui.widgetBox(self.controlArea, box=True) + gui.checkBox( + box, self.graph, 'show_legend',"Show legend", + callback=self.graph.update_legend_visibility) + gui.checkBox( + box, self, 'contours_enabled', label="Show contours", + callback=self.plot_contour) gui.rubber(self.controlArea) - # chart - self.scatter = Scatterplot( - xAxis_gridLineWidth=0, yAxis_gridLineWidth=0, - xAxis_startOnTick=False, xAxis_endOnTick=False, - yAxis_startOnTick=False, yAxis_endOnTick=False, - xAxis_lineWidth=0, yAxis_lineWidth=0, - yAxis_tickWidth=1, title_text='', tooltip_shared=False) - - # Just render an empty chart so it shows a nice 'No data to display' - self.scatter.chart() - self.mainArea.layout().addWidget(self.scatter) + def add_bottom_buttons(self): + pass + def _on_degree_changed(self): self.init_learner() + self.apply() + + def _on_attr_changed(self): + self.select_data() + self.apply() + + def _on_target_changed(self): + self.select_data() + self.apply() + ############################## + # Input signal-related stuff @Inputs.learner def set_learner(self, learner): - """ - Function is sets learner when learner is changed on input - """ - self.learner_other = learner + self.input_learner = learner self.init_learner() def set_preprocessor(self, preprocessor): - """ - Function adds preprocessor when it changed on input - """ self.preprocessors = [preprocessor] if preprocessor else [] self.init_learner() + @Inputs.data def set_data(self, data): - """ - Function receives data from input and init part of widget if data - satisfy. Otherwise set empty plot and notice - user about that - - Parameters - ---------- - data : Table - Input data - """ - - def reset_combos(): - self.x_var_model[:] = [] - self.y_var_model[:] = [] - self.target_class_combobox.clear() - - def init_combos(): - """ - function initialize the combos with attributes - """ - reset_combos() - - c_vars = [var for var in data.domain.variables if var.is_continuous] - - self.x_var_model[:] = c_vars - self.y_var_model[:] = c_vars - - for i, var in enumerate(data.domain.class_var.values): - pix_map = QPixmap(60, 60) - color = tuple(data.domain.class_var.colors[i].tolist()) - pix_map.fill(QColor(*color)) - self.target_class_combobox.addItem(QIcon(pix_map), var) + combo = self.controls.target_class + self.closeContext() self.Error.clear() - - # clear variables + combo.clear() + self.var_model.set_domain(None) + self.data = self.selected_data = None self.xv = None self.yv = None self.probabilities_grid = None - if data is None or len(data) == 0: - self.data = None - reset_combos() - self.set_empty_plot() - elif sum(True for var in data.domain.attributes - if isinstance(var, ContinuousVariable)) < 2: - self.data = None - reset_combos() - self.Error.to_few_features() - self.set_empty_plot() - elif (data.domain.class_var is None or - data.domain.class_var.is_continuous or - sum(line.get_class() == None for line in data) == len(data) or - len(data.domain.class_var.values) < 2): - self.data = None - reset_combos() + if not data: + return + domain = data.domain + if domain.class_var is None or domain.class_var.is_continuous: self.Error.no_class() - self.set_empty_plot() - else: - self.data = data - init_combos() - self.attr_x = self.cbx.itemText(0) - self.attr_y = self.cbx.itemText(1) - self.target_class = self.target_class_combobox.itemText(0) + return + if sum(var.is_continuous + for var in chain(domain.variables, domain.metas)) < 2: + self.Error.num_features() + return - self.apply() + self.data = data + non_empty = np.bincount(data.Y[np.isfinite(data.Y)].astype(int)) > 0 + values = np.array(domain.class_var.values)[non_empty] + combo.addItems(values.tolist()) + self.var_model.set_domain(self.data.domain) + self.attr_x, self.attr_y = self.var_model[:2] + self.target_class = combo.itemText(0) + + hide_attrs = len(self.var_model) == 2 + self.controls.attr_x.setHidden(hide_attrs) + self.controls.attr_y.setHidden(hide_attrs) + + self.openContext(self.data) + self.select_data() def init_learner(self): - """ - Function init learner and add preprocessors to learner - """ - if self.learner_other is not None and \ - self.learner_other.__class__.__name__ == "LinearRegressionLearner": - # in case that learner is a Linear Regression - self.learner = None - self.Error.no_classifier() - else: - self.learner = (copy.deepcopy(self.learner_other) or - self.LEARNER(penalty='l2', C=1e10)) - self.learner.preprocessors = ( - [self.default_preprocessor(self.degree)] + - list(self.preprocessors or []) + - list(self.learner.preprocessors or [])) - self.Error.no_classifier.clear() + self.learner = copy.copy(self.input_learner + or self.LEARNER(penalty='l2', C=1e10)) + self.learner.preprocessors = ( + [PolynomialTransform(self.degree)] + + list(self.preprocessors or []) + + list(self.learner.preprocessors or [])) + self.send_learner() + + def handleNewSignals(self): self.apply() - def set_empty_plot(self): - """ - Function inits empty plot - """ - self.scatter.clear() - - def replot(self): - """ - This function performs complete replot of the graph - """ - if self.data is None or self.selected_data is None: - self.set_empty_plot() + def select_data(self): + """Put the two selected columns in a new Orange.data.Table""" + self.Error.no_nonnan_data.clear() + self.Error.same_variable.clear() + + attr_x, attr_y = self.attr_x, self.attr_y + if self.attr_x is self.attr_y: + self.selected_data = None + self.Error.same_variable() return - attr_x = self.data.domain[self.attr_x] - attr_y = self.data.domain[self.attr_y] - data_x = [v[0] for v in self.data[:, attr_x] if not isnan(v[0])] - data_y = [v[0] for v in self.data[:, attr_y] if not isnan(v[0])] - min_x = min(data_x) - max_x = max(data_x) - min_y = min(data_y) - max_y = max(data_y) - # just in cas that diff is 0 - diff_x = (max_x - min_x) if abs(max_x - min_x) > 0.001 else 0.1 - diff_y = (max_y - min_y) if abs(max_y - min_y) > 0.001 else 0.1 - min_x, max_x = min_x - 0.03 * diff_x, max_x + 0.03 * diff_x - min_y, max_y = min_y - 0.03 * diff_y, max_y + 0.03 * diff_y - - options = dict(series=[]) - - # gradient and contour - options['series'] += self.plot_gradient_and_contour( - min_x, max_x, min_y, max_y) - - sd = self.selected_data - # data points - options['series'] += [ - dict( - data=[list(p.attributes()) - for p in sd - if (p.metas[0] == _class and - all(v is not None for v in p.attributes()))], - type="scatter", - zIndex=10, - color=rgb_to_hex(tuple( - sd.domain.metas[0].colors[_class].tolist())), - showInLegend=True, - name=sd.domain.metas[0].values[_class]) - for _class in range(len(sd.domain.metas[0].values))] - - # add nan values as a gray dots - options['series'] += [ - dict( - data=[list(p.attributes()) - for p in sd - if np.isnan(p.metas[0])], - type="scatter", - zIndex=10, - color=rgb_to_hex((160, 160, 160)), - showInLegend=False)] - - cls_domain = sd.domain.metas[0] - - target_idx = cls_domain.values.index(self.target_class) - target_color = tuple(cls_domain.colors[target_idx].tolist()) - other_color = (tuple(cls_domain.colors[(target_idx + 1) % 2].tolist()) - if len(cls_domain.values) == 2 else (170, 170, 170)) - - # highcharts parameters - kwargs = dict( - xAxis_title_text=attr_x.name, - yAxis_title_text=attr_y.name, - xAxis_min=min_x, - xAxis_max=max_x, - yAxis_min=min_y, - yAxis_max=max_y, - colorAxis=dict( - labels=dict(enabled=False), - stops=[ - [0, rgb_hash_brighter(rgb_to_hex(other_color), 0.5)], - [0.5, '#ffffff'], - [1, rgb_hash_brighter(rgb_to_hex(target_color), 0.5)]], - tickInterval=0.2, min=0, max=1), - plotOptions_contour_colsize=(max_y - min_y) / 1000, - plotOptions_contour_rowsize=(max_x - min_x) / 1000, - legend=dict( - enabled=self.legend_enabled, - layout='vertical', - align='right', - verticalAlign='top', - floating=True, - backgroundColor='rgba(255, 255, 255, 0.3)', - symbolWidth=0, - symbolHeight=0), - tooltip_headerFormat="", - tooltip_pointFormat="%s: {point.x:.2f}
" - "%s: {point.y:.2f}" % - (self.attr_x, self.attr_y)) - - self.scatter.chart(options, **kwargs) + names = [var.name for var in (attr_x, attr_y)] + if names == ["x", "y"]: + names = [""] * 2 + for place, name in zip(("bottom", "left"), names): + self.graph.plot_widget.getAxis(place).setLabel(name) + old_class = self.data.domain.class_var + values = old_class.values + target_idx = values.index(self.target_class) + + new_class = DiscreteVariable( + old_class.name + "'", + values=(values[1 - target_idx] if len(values) == 2 else 'Others', + self.target_class), + compute_value=Indicator(old_class, target_idx)) + new_class.palette = DiscretePalette( + "indicator", "indicator", + [[64, 64, 64], list(old_class.palette.palette[target_idx])]) + + domain = Domain([attr_x, attr_y], new_class, [old_class]) + + self.selected_data = self.data.transform(domain) + valid_data = \ + np.flatnonzero( + np.all( + np.isfinite(self.selected_data.X), + axis=1) + ) + if not valid_data.size: + self.Error.no_nonnan_data() + self.selected_data = None + else: + self.selected_data = self.selected_data[valid_data] + + + def apply(self): + self.update_model() + self.send_model() + self.send_coefficients() + self.send_data() + + self.graph.reset_graph() + self.graph.plot_widget.addItem(self.contour_label) # Re-add the label + self.plot_gradient() self.plot_contour() - def plot_gradient_and_contour(self, x_from, x_to, y_from, y_to): - """ - Function constructs series for gradient and contour - - Parameters - ---------- - x_from : float - Min grid x value - x_to : float - Max grid x value - y_from : float - Min grid y value - y_to : float - Max grid y value - - Returns - ------- - list - List containing series with background gradient and contour - """ - - # grid for gradient - x = np.linspace(x_from, x_to, self.grid_size) - y = np.linspace(y_from, y_to, self.grid_size) + def update_model(self): + self.Error.fitting_failed.clear() + self.model = None + self.probabilities_grid = None + if self.selected_data is not None and self.learner is not None: + try: + self.model = self.learner(self.selected_data) + self.model.name = self.learner_name + except Exception as e: + self.Error.fitting_failed(str(e)) + + ############################## + # Graph and its contents + def _add_graph(self): + self.graph = PolynomialPlot(self) + self.graph.plot_widget.setCursor(QCursor(Qt.CrossCursor)) + self.mainArea.layout().addWidget(self.graph.plot_widget) + self.graph.point_width = 1 + + axis_color = self.palette().color(QPalette.Text) + axis_pen = QPen(axis_color) + + tickfont = QFont(self.font()) + tickfont.setPixelSize(max(int(tickfont.pixelSize() * 2 // 3), 11)) + + for pos in ("bottom", "left"): + axis = self.graph.plot_widget.getAxis(pos) + axis.setPen(axis_pen) + axis.setTickFont(tickfont) + axis.show() + + self._hover_delegate = HoverEventDelegate(self.help_event) + self.graph.plot_widget.scene().installEventFilter(self._hover_delegate) + + self.contour_label = label = QGraphicsTextItem() + label.setFlag(QGraphicsTextItem.ItemIgnoresTransformations) + self.graph.plot_widget.addItem(label) + label.hide() + # I'm not proud of this and will brew a coffee to the person who + # improves it (and comes to claim it) + self.graph.plot_widget.scene().leaveEvent = lambda *_: label.hide() + + @staticmethod + def _contour_pen(value, hovered): + return pg.mkPen(0.2, + width=1 + 2 * hovered + (value == 0.5), + style=Qt.SolidLine) + # Alternative: + # Qt.SolidLine if hovered or value == 0.5 else Qt.DashDotLine + + def help_event(self, event): + if self.probabilities_grid is None: + return + + pos = event.scenePos() + pos = self.graph.plot_widget.mapToView(pos) + + # The mouse hover width is a bit larger for easier hovering, but + # consequently more than one line can be hovered. Pick the middle one. + hovereds = [item for item in self.contours + if item.mouseShape().contains(pos)] + hovered = hovereds[len(hovereds) // 2] if hovereds else None + # Set the pen for all lines, hovered and not hovered (any longer) + for item in self.contours: + item.setPen(self._contour_pen(item.value, item is hovered)) + + # Set the probability for the textitem at mouse position. + # If there is a hovered line - this acts as a line label. + # Otherwise, take the probability from the grid + label = self.contour_label + if hovered: + prob = hovered.value + else: + min_x, step_x, min_y, step_y = self.probabilities_grid_dimensions + prob = self.probabilities_grid.T[ + int(np.clip(round((pos.x() - min_x) * step_x), 0, GRID_SIZE - 1)), + int(np.clip(round((pos.y() - min_y) * step_y), 0, GRID_SIZE - 1))] + prob_lab = f"{round(prob, 3):.3g}" + if "." not in prob_lab: + prob_lab += ".0" # Showing just 0 or 1 looks ugly + label.setHtml(prob_lab) + font = label.font() + font.setBold(hovered is not None) + label.setFont(font) + + # Position the label above and left from mouse; looks nices + rect = label.boundingRect() + spos = event.scenePos() + x, y = spos.x() - rect.width(), spos.y() - rect.height() + label.setPos(self.graph.plot_widget.mapToView(QPointF(x, y))) + + label.show() + return True + + def changeEvent(self, ev): + # This hides the label if the user alt-tabs out of the window + if ev.type() == QEvent.ActivationChange and not self.isActiveWindow(): + self.contour_label.hide() + super().changeEvent(ev) + + def plot_gradient(self): + if not self.model: + self.probabilities_grid = None + return + + (min_x, max_x), (min_y, max_y) = self.graph.view_box.viewRange() + x = np.linspace(min_x, max_x, GRID_SIZE) + y = np.linspace(min_y, max_y, GRID_SIZE) self.xv, self.yv = np.meshgrid(x, y) - # parameters to predict from grid attr = np.hstack((self.xv.reshape((-1, 1)), self.yv.reshape((-1, 1)))) - attr_data = Table.from_numpy( - self.selected_data.domain, attr, - np.array([[None]] * len(attr)), - np.array([[None]] * len(attr)) - ) - - # results - self.probabilities_grid = self.model(attr_data, 1)[:, 0]\ + nil = np.full((len(attr), 1), np.nan) + attr_data = Table.from_numpy(self.selected_data.domain, attr, nil, nil) + + self.probabilities_grid = self.model(attr_data, 1)[:, 1] \ .reshape(self.xv.shape) + self.probabilities_grid_dimensions = ( + min_x, GRID_SIZE / (max_x - min_x), + min_y, GRID_SIZE / (max_y - min_y)) + + if not self._treelike: + self.probabilities_grid = self.blur_grid(self.probabilities_grid) + + bitmap = self.probabilities_grid.copy() + bitmap *= 255 + bitmap = bitmap.astype(np.uint8) + + class_var = self.selected_data.domain.class_var + h1, s1, v1 = rgb_to_hsv(*class_var.colors[1] / 255) + palette = np.vstack(( + np.linspace([h1, 0, 0.8], [h1, 0, 1], 128), + np.linspace([h1, 0, 1], [h1, s1 * 0.5, 0.7 + 0.3 * v1], 128) + )) + palette = 255 * np.array([hsv_to_rgb(*col) for col in palette]) + palette = palette.astype(int) + + density_img = pg.ImageItem(bitmap.T, lut=palette) - blurred = self.blur_grid(self.probabilities_grid) + density_img.setRect(QRectF(min_x, min_y, + max_x - min_x, max_y - min_y)) + density_img.setZValue(-1) + self.graph.plot_widget.addItem(density_img, ignoreBounds=True) - is_tree = type(self.learner) in [RandomForestLearner, TreeLearner] - return self.plot_gradient(self.xv, self.yv, - self.probabilities_grid - if is_tree else blurred) + def remove_contours(self): + while self.contours: + self.graph.plot_widget.removeItem(self.contours.pop()) - def plot_gradient(self, x, y, grid): - """ - Function constructs background gradient - """ - return [dict(data=[[x[j, k], y[j, k], grid[j, k]] for j in range(len(x)) - for k in range(y.shape[1])], - grid_width=self.grid_size, - type="contour")] + @property + def _treelike(self): + return isinstance(self.learner, (RandomForestLearner, TreeLearner)) def plot_contour(self): - """ - Function constructs contour lines - """ - self.scatter.remove_contours() - if not self.data: + self.remove_contours() + if self.probabilities_grid is None or not self.contours_enabled: return - if self.contours_enabled: - is_tree = type(self.learner) in [RandomForestLearner, TreeLearner] - # tree does not need smoothing - contour = Contour( - self.xv, self.yv, self.probabilities_grid - if is_tree else self.blur_grid(self.probabilities_grid)) - contour_lines = contour.contours( - np.hstack( - (np.arange(0.5, 0, - self.contour_step)[::-1], - np.arange(0.5 + self.contour_step, 1, self.contour_step)))) - # we want to have contour for 0.5 - - series = [] - count = 0 - for key, value in contour_lines.items(): - for line in value: - if (len(line) > self.degree and - type(self.learner) not in - [RandomForestLearner, TreeLearner]): - # if less than degree interpolation fails - tck, u = splprep( - [list(x) for x in zip(*reversed(line))], - s=0.001, k=self.degree, - per=(len(line) - if np.allclose(line[0], line[-1]) - else 0)) - new_int = np.arange(0, 1.01, 0.01) - interpol_line = np.array(splev(new_int, tck)).T.tolist() - else: - interpol_line = line - - series.append(dict(data=self.labeled(interpol_line, count), - color=self.contour_color, - type="spline", - lineWidth=0.5, - showInLegend=False, - marker=dict(enabled=False), - name="%g" % round(key, 2), - enableMouseTracking=False - )) - count += 1 - self.scatter.add_series(series) - self.scatter.redraw_series() + + contour = Contour(self.xv, self.yv, self.probabilities_grid) + contour_lines = contour.contours(np.arange(0.1, 1, 0.1)) + for key, value in contour_lines.items(): + for line in value: + if len(line) > self.degree and not self._treelike: + tck, u = splprep( + [list(x) for x in zip(*reversed(line))], + s=0.001, k=self.degree, + per=(len(line) if np.allclose(line[0], line[-1]) + else 0)) + new_int = np.arange(0, 1.01, 0.01) + interpol_line = np.array(splev(new_int, tck)).T.tolist() + else: + interpol_line = line + contour = pg.PlotCurveItem( + *np.array(list(interpol_line)).T, + pen=self._contour_pen(key, False)) + # The hover region can be narrowed by calling setClickable + # (with False, to keep it unclickable) + contour.value = key + self.graph.plot_widget.addItem(contour) + self.contours.append(contour) @staticmethod def blur_grid(grid): @@ -511,158 +472,67 @@ def blur_grid(grid): (grid < 0.55)] return filtered - @staticmethod - def labeled(data, no): - """ - Function labels data with contour levels - """ - point = (no * 5) # to avoid points on same positions - point += (1 if point == 0 else 0) - point %= len(data) - - data[point] = dict( - x=data[point][0], - y=data[point][1], - dataLabels=dict( - enabled=True, - format="{series.name}", - verticalAlign='middle', - style=dict( - fontWeight="normal", - color=OWPolynomialClassification.contour_color, - textShadow=False - ))) - return data - - def select_data(self): - """ - Function takes two selected columns from data table and merge them - in new Orange.data.Table - - Returns - ------- - Table - Table with selected columns - """ - self.Error.clear() - - attr_x = self.data.domain[self.attr_x] - attr_y = self.data.domain[self.attr_y] - cols = [] - for attr in (attr_x, attr_y): - subset = self.data[:, attr] - cols.append(subset.X if not sp.issparse(subset.X) else subset.X.toarray()) - x = np.column_stack(cols) - y_c = self.data.Y[:, None] if not sp.issparse(self.data.Y) else self.data.Y.toarray() - - if np.isnan(x).all(axis=0).any(): - self.Error.all_none_data() - return None + # The following methods are required by OWScatterPlotBase + def get_coordinates_data(self): + if not self.selected_data: + return None, None + return self.selected_data.X.T - cls_domain = self.data.domain.class_var - target_idx = cls_domain.values.index(self.target_class) - other_value = cls_domain.values[(target_idx + 1) % 2] + def get_color_data(self): + return self.selected_data and self.selected_data.Y - class_domain = [DiscreteVariable( - name="Transformed " + self.data.domain.class_var.name, - values=(self.target_class, 'Others' - if len(cls_domain.values) > 2 else other_value))] + def get_palette(self): + return self.selected_data and self.selected_data.domain.class_var.palette - domain = Domain( - [attr_x, attr_y], - class_domain, - [self.data.domain.class_var]) - y = [(0 if d.get_class().value == self.target_class else 1) - for d in self.data] + def get_color_labels(self): + return self.selected_data and self.selected_data.domain.class_var.values - return Table.from_numpy(domain, x, y, y_c) + def is_continuous_color(self): + return False - def apply(self): - """ - Applies leaner and sends new model and coefficients - """ - self.send_learner() - self.update_model() - self.send_coefficients() - if any(a is None for a in (self.data, self.model)): - self.set_empty_plot() - else: - self.replot() - self.send_data() + get_size_data = get_shape_data = get_shape_labels = \ + get_subset_mask = get_label_data = get_tooltip = selection_changed = \ + lambda *_: None + ############################## + # Output signal-related stuff def send_learner(self): - """ - Function sends learner on widget's output - """ if self.learner is not None: self.learner.name = self.learner_name self.Outputs.learner.send(self.learner) - def update_model(self): - """ - Function sends model on widget's output - """ - self.Error.fitting_failed.clear() - self.model = None - if self.data is not None and self.learner is not None: - self.selected_data = self.select_data() - if self.selected_data is not None: - try: - self.model = self.learner(self.selected_data) - self.model.name = self.learner_name - self.model.instances = self.selected_data - except Exception as e: - self.Error.fitting_failed(str(e)) - + def send_model(self): self.Outputs.model.send(self.model) def send_coefficients(self): - """ - Function sends coefficients on widget's output if model has them - """ - - if (self.model is not None and - isinstance(self.learner, LogisticRegressionLearner) and - hasattr(self.model, 'skl_model')): - model = self.model.skl_model - domain = Domain( - [ContinuousVariable("coef")], metas=[StringVariable("name")]) - coefficients = (model.intercept_.tolist() + - model.coef_[0].tolist()) - - data = self.model.instances - for preprocessor in self.learner.preprocessors: - data = preprocessor(data) - names = ["Intercept"] + [x.name for x in data.domain.attributes] - - coefficients_table = Table.from_list( - domain, list(zip(coefficients, names))) - self.Outputs.coefficients.send(coefficients_table) - else: + if (self.model is None + or not isinstance(self.learner, LogisticRegressionLearner) + or not hasattr(self.model, 'skl_model')): self.Outputs.coefficients.send(None) - - def send_data(self): - """ - Function sends data on widget's output - """ - if self.data is not None: - data = self.selected_data - self.Outputs.data.send(data) return - self.Outputs.data.send(None) - def add_bottom_buttons(self): - pass + model = self.model.skl_model + domain = Domain( + [ContinuousVariable("coef")], metas=[StringVariable("name")]) + coefficients = model.intercept_.tolist() + model.coef_[0].tolist() + names = ["Intercept"] + [x.name for x in self.model.domain.attributes] + coefficients_table = Table.from_list( + domain, list(zip(coefficients, names))) + self.Outputs.coefficients.send(coefficients_table) + + def send_data(self): + if self.selected_data is None: + self.Outputs.data.send(None) + else: + expanded = PolynomialTransform(self.degree)(self.selected_data) + self.Outputs.data.send(expanded) def send_report(self): - if self.data is None: + if self.selected_data is None: return - caption = report.render_items_vert(( - ("Polynomial Expansion", self.degree), - )) - self.report_plot(self.scatter) - if caption: - self.report_caption(caption) + name = "" if self.degree == 1 \ + else f"Model with polynomial expansion {self.degree}" + self.report_plot(name=name, plot=self.graph.plot_widget) if __name__ == "__main__": diff --git a/orangecontrib/educational/widgets/tests/test_owpolynomialclassification.py b/orangecontrib/educational/widgets/tests/test_owpolynomialclassification.py index cc2928d..ef69357 100644 --- a/orangecontrib/educational/widgets/tests/test_owpolynomialclassification.py +++ b/orangecontrib/educational/widgets/tests/test_owpolynomialclassification.py @@ -1,544 +1,255 @@ -from functools import reduce import unittest +from unittest.mock import Mock import numpy as np import scipy.sparse as sp from numpy.testing import assert_array_equal -from Orange.regression import LinearRegressionLearner -from Orange.widgets.tests.base import WidgetTest from Orange.data import Table, ContinuousVariable, Domain, DiscreteVariable -from Orange.classification import ( - LogisticRegressionLearner, - TreeLearner, - RandomForestLearner, SVMLearner) +from Orange.classification import \ + LogisticRegressionLearner, TreeLearner, SVMLearner from Orange.preprocess.preprocess import Continuize, Discretize +from Orange.widgets.tests.base import WidgetTest + from orangecontrib.educational.widgets.owpolynomialclassification import \ - OWPolynomialClassification + OWPolynomialClassification, GRID_SIZE from orangecontrib.educational.widgets.utils.polynomialtransform import \ PolynomialTransform -class TestOWPolynomialClassification(WidgetTest): - +class TestOWPolynomialClassificationNoGrid(WidgetTest): + # Tests with mocked computation of probability grid and contours + # These are never tested and just slow everything down. def setUp(self): - self.widget = self.create_widget(OWPolynomialClassification) # type: OWPolynomialClassification - self.iris = Table.from_file("iris") - - def test_add_main_layout(self): - """ - With this test we check if everything is ok on widget init - """ - w = self.widget - - # add main layout is called when function is initialized - - # check just if it is initialize on widget start - self.assertIsNotNone(w.options_box) - self.assertIsNotNone(w.cbx) - self.assertIsNotNone(w.cby) - self.assertIsNotNone(w.target_class_combobox) - self.assertIsNotNone(w.degree_spin) - self.assertIsNotNone(w.plot_properties_box) - self.assertIsNotNone(w.contours_enabled_checkbox) - self.assertIsNotNone(w.contour_step_slider) - self.assertIsNotNone(w.scatter) - - # default learner must be logistic regression - self.assertEqual(w.LEARNER.name, LogisticRegressionLearner.name) + # type: OWPolynomialClassification + self.widget = self.create_widget(OWPolynomialClassification) - # widget have to be resizable - self.assertTrue(w.resizing_enabled) + def plot_gradient(): + self.widget.probabilities_grid = self.widget.model and np.zeros((GRID_SIZE, GRID_SIZE)) - # learner should be Logistic regression - self.assertTrue(isinstance(w.learner, LogisticRegressionLearner)) + self.widget.plot_gradient = plot_gradient + self.widget.plot_contour = Mock() - # preprocessor should be PolynomialTransform - self.assertEqual( - type(w.default_preprocessor), type(PolynomialTransform)) - - # check if there is learner on output - self.assertEqual(self.get_output(w.Outputs.learner), w.learner) + self.iris = Table.from_file("iris") - # model and coefficients should be none because of no data + def test_init(self): + w = self.widget + self.assertIsInstance(w.learner, LogisticRegressionLearner) + self.assertIsInstance(self.get_output(w.Outputs.learner), + LogisticRegressionLearner) self.assertIsNone(self.get_output(w.Outputs.model)) self.assertIsNone(self.get_output(w.Outputs.coefficients)) - # this parameters are none because no plot should be called - self.assertIsNone(w.xv) - self.assertIsNone(w.yv) - self.assertIsNone(w.probabilities_grid) - - def test_set_learner_empty(self): - """ - Test if learner is set correctly when no learner provided - """ - w = self.widget - - # check if empty - self.assertEqual(w.learner_other, None) - self.assertTrue(isinstance(w.learner, LogisticRegressionLearner)) - self.assertTrue(isinstance(w.learner, w.LEARNER)) - self.assertEqual( - type(self.get_output(w.Outputs.learner)), type(LogisticRegressionLearner())) - def test_set_learner(self): - """ - Test if learner is set correctly - """ w = self.widget + self.assertIsInstance(w.learner, LogisticRegressionLearner) + self.assertIsInstance(self.get_output(w.Outputs.learner), + LogisticRegressionLearner) - learner = TreeLearner() + self.send_signal(w.Inputs.learner, SVMLearner()) + self.assertIsInstance(self.get_output(w.Outputs.learner), SVMLearner) - self.send_signal(w.Inputs.learner, learner) - # check if learners set correctly - self.assertEqual(w.learner_other, learner) - self.assertEqual(type(w.learner), type(learner)) - self.assertEqual(type(self.get_output(w.Outputs.learner)), type(learner)) - - # after learner is removed there should be LEARNER used self.send_signal(w.Inputs.learner, None) - self.assertEqual(w.learner_other, None) - self.assertTrue(isinstance(w.learner, LogisticRegressionLearner)) - self.assertTrue(isinstance(w.learner, w.LEARNER)) - self.assertEqual( - type(self.get_output(w.Outputs.learner)), type(LogisticRegressionLearner())) + self.assertIsInstance(w.learner, LogisticRegressionLearner) + self.assertIsInstance(self.get_output(w.Outputs.learner), + LogisticRegressionLearner) def test_set_preprocessor(self): - """ - Test preprocessor set - """ w = self.widget preprocessor = Continuize() - - # check if empty - self.assertIn(w.preprocessors, [[], None]) - self.send_signal(w.Inputs.preprocessor, preprocessor) - # check preprocessor is set - self.assertEqual(w.preprocessors, [preprocessor]) - self.assertIn(preprocessor, self.get_output(w.Outputs.learner).preprocessors) - - # remove preprocessor - self.send_signal(w.Inputs.preprocessor, None) + for learner in (None, SVMLearner(), None): + self.send_signal(w.Inputs.learner, learner) - self.assertIn(w.preprocessors, [[], None]) - self.assertNotIn(preprocessor, self.get_output(w.Outputs.learner).preprocessors) + self.assertEqual(w.preprocessors, [preprocessor]) + preprocessors = self.get_output(w.Outputs.learner).preprocessors + self.assertIn(preprocessor, preprocessors) + self.assertTrue(isinstance(pp, PolynomialTransform) + for pp in preprocessors) - # change preprocessor - preprocessor = Continuize() - self.send_signal(w.Inputs.preprocessor, preprocessor) + self.send_signal(w.Inputs.preprocessor, None) + self.assertIn(w.preprocessors, [[], None]) + self.assertNotIn(preprocessor, self.get_output(w.Outputs.learner).preprocessors) + self.assertTrue(isinstance(pp, PolynomialTransform) + for pp in preprocessors) - # check preprocessor is set - self.assertEqual(w.preprocessors, [preprocessor]) - self.assertIn(preprocessor, self.get_output(w.Outputs.learner).preprocessors) + self.send_signal(w.Inputs.preprocessor, preprocessor) + self.assertEqual(w.preprocessors, [preprocessor]) + self.assertIn(preprocessor, self.get_output(w.Outputs.learner).preprocessors) + self.assertTrue(isinstance(pp, PolynomialTransform) + for pp in preprocessors) def test_set_data(self): - """ - Test widget behavior when data set - """ w = self.widget - - num_continuous_attributes = sum( - True for var in self.iris.domain.attributes - if isinstance(var, ContinuousVariable)) + attr0, attr1 = self.iris.domain.attributes[:2] + class_vals = self.iris.domain.class_var.values self.send_signal(w.Inputs.data, self.iris[::15]) - # widget does not have any problems with that data set so - # everything should be fine - self.assertEqual(w.cbx.count(), num_continuous_attributes) - self.assertEqual(w.cby.count(), num_continuous_attributes) - self.assertEqual( - w.target_class_combobox.count(), - len(self.iris.domain.class_var.values)) - self.assertEqual(w.cbx.currentText(), self.iris.domain[0].name) - self.assertEqual(w.cby.currentText(), self.iris.domain[1].name) - self.assertEqual( - w.target_class_combobox.currentText(), - self.iris.domain.class_var.values[0]) - - self.assertEqual(w.attr_x, self.iris.domain[0].name) - self.assertEqual(w.attr_y, self.iris.domain[1].name) - self.assertEqual(w.target_class, self.iris.domain.class_var.values[0]) - - # change showed attributes - w.attr_x = self.iris.domain[1].name - w.attr_y = self.iris.domain[2].name - w.target_class = self.iris.domain.class_var.values[1] - - self.assertEqual(w.cbx.currentText(), self.iris.domain[1].name) - self.assertEqual(w.cby.currentText(), self.iris.domain[2].name) - self.assertEqual( - w.target_class_combobox.currentText(), - self.iris.domain.class_var.values[1]) - - self.assertEqual(w.attr_x, self.iris.domain[1].name) - self.assertEqual(w.attr_y, self.iris.domain[2].name) - self.assertEqual(w.target_class, self.iris.domain.class_var.values[1]) + self.assertEqual(w.var_model.rowCount(), 4) + self.assertEqual(w.controls.target_class.count(), len(class_vals)) + self.assertIs(w.attr_x, attr0) + self.assertIs(w.attr_y, attr1) + self.assertEqual(w.target_class, class_vals[0]) # remove data set self.send_signal(w.Inputs.data, None) - self.assertEqual(w.cbx.count(), 0) - self.assertEqual(w.cby.count(), 0) - self.assertEqual(w.target_class_combobox.count(), 0) + self.assertEqual(w.var_model.rowCount(), 0) + self.assertEqual(w.controls.target_class.count(), 0) + + def _set_iris(self): + # Set some data so the widget goes up and test can check that it is + # torn down when erroneous data is received + w = self.widget + self.send_signal(w.Inputs.data, self.iris) + self.assert_all_up() + + def assert_all_up(self): + w = self.widget + self.assertNotEqual(len(w.var_model), 0) + self.assertNotEqual(w.controls.target_class.count(), 0) + self.assertIsNotNone(w.data) + self.assertIsNotNone(w.selected_data) + self.assertIsNotNone(w.model) + self.assertIsNotNone(w.probabilities_grid) + + def assert_all_down(self): + w = self.widget + self.assertEqual(len(w.var_model), 0) + self.assertEqual(w.controls.target_class.count(), 0) + self.assertIsNone(w.data) + self.assertIsNone(w.selected_data) + self.assertIsNone(w.model) + self.assertIsNone(w.probabilities_grid) def test_set_data_no_class(self): - """ - Test widget on data with no class - """ w = self.widget - table_no_class = Table.from_list( - Domain([ContinuousVariable("x"), ContinuousVariable("y")]), - [[1, 2], [2, 3]]) + self._set_iris() + + table_no_class = self.iris.transform(Domain(self.iris.domain.attributes, [])) self.send_signal(w.Inputs.data, table_no_class) + self.assert_all_down() - self.assertEqual(w.cbx.count(), 0) - self.assertEqual(w.cby.count(), 0) - self.assertEqual(w.target_class_combobox.count(), 0) - self.assertTrue(w.Error.no_class.is_shown()) + def test_set_data_regression(self): + w = self.widget + self._set_iris() + + table_regr = Table.from_numpy( + Domain(self.iris.domain.attributes[:3], + self.iris.domain.attributes[3]), + self.iris.X[:, :3], self.iris.X[:, 3]) + self.send_signal(w.Inputs.data, table_regr) + self.assert_all_down() def test_set_data_one_class(self): - """ - Test widget on data with one class variable - """ w = self.widget + self._set_iris() - table_one_class = Table.from_list( - Domain([ContinuousVariable("x"), ContinuousVariable("y")], + table_one_class = Table.from_numpy( + Domain(self.iris.domain.attributes, DiscreteVariable("a", values=("k", ))), - [[1, 2], [2, 3]], [0, 0]) + self.iris.X, np.zeros(150)) self.send_signal(w.Inputs.data, table_one_class) - self.assertEqual(w.cbx.count(), 0) - self.assertEqual(w.cby.count(), 0) - self.assertEqual(w.target_class_combobox.count(), 0) - self.assertTrue(w.Error.no_class.is_shown()) + self.assertEqual(len(w.var_model), 4) + self.assertEqual(w.controls.target_class.count(), 1) + self.assertIsNotNone(w.data) + self.assertIsNotNone(w.selected_data) + self.assertIsNone(w.model) + self.assertIsNone(w.probabilities_grid) + self.assertTrue(w.Error.fitting_failed.is_shown()) def test_set_data_wrong_var_number(self): - """ - Test widget on data with not enough continuous variables - """ w = self.widget - # + self._set_iris() + table_no_enough_cont = Table.from_numpy( Domain( [ContinuousVariable("x"), DiscreteVariable("y", values=("a", "b"))], - ContinuousVariable("a")), + DiscreteVariable("a", values=("a", "b"))), [[1, 0], [2, 1]], [0, 0]) self.send_signal(w.Inputs.data, table_no_enough_cont) - self.assertEqual(w.cbx.count(), 0) - self.assertEqual(w.cby.count(), 0) - self.assertEqual(w.target_class_combobox.count(), 0) - self.assertTrue(w.Error.to_few_features.is_shown()) - - def test_init_learner(self): - """ - Test init - """ - w = self.widget - - learner = TreeLearner() - - # check if empty - self.assertTrue(isinstance(w.learner, LogisticRegressionLearner)) - self.assertTrue(isinstance(w.learner, w.LEARNER)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, w.default_preprocessor), - w.learner.preprocessors, False)) - - self.send_signal(w.Inputs.learner, learner) - - # check if learners set correctly - self.assertEqual(type(w.learner), type(learner)) - - # after learner is removed there should be LEARNER used - self.send_signal(w.Inputs.learner, None) - self.assertTrue(isinstance(w.learner, LogisticRegressionLearner)) - self.assertTrue(isinstance(w.learner, w.LEARNER)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, w.default_preprocessor), - w.learner.preprocessors, False)) - - # set it again just in case something goes wrong - learner = RandomForestLearner() - self.send_signal(w.Inputs.learner, learner) - - self.assertEqual(type(w.learner), type(learner)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, w.default_preprocessor), - w.learner.preprocessors, False)) - - # change learner this time not from None - learner = TreeLearner() - self.send_signal(w.Inputs.learner, learner) - - self.assertEqual(type(w.learner), type(learner)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, w.default_preprocessor), - w.learner.preprocessors, False)) - - # set other preprocessor - preprocessor = Discretize - # selected this preprocessor because know that not exist in LogReg - self.send_signal(w.Inputs.preprocessor, preprocessor()) - - self.assertEqual(type(w.learner), type(learner)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, w.default_preprocessor), - w.learner.preprocessors, False)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, preprocessor), - w.learner.preprocessors, False)) - - # remove preprocessor - self.send_signal(w.Inputs.preprocessor, None) - self.assertEqual(type(w.learner), type(learner)) - self.assertTrue( - reduce(lambda x, y: x or isinstance(y, w.default_preprocessor), - w.learner.preprocessors, False)) - - self.assertFalse(reduce(lambda x, y: x or isinstance(y, preprocessor), - w.learner.preprocessors, False)) - - def test_replot(self): - """ - Test everything that is possible to test in replot - This function tests all replot functions - """ - w = self.widget - - w.replot() - - # test nothing happens when no data - self.assertIsNone(w.xv) - self.assertIsNone(w.yv) - self.assertIsNone(w.probabilities_grid) - - # when data available plot happens - self.send_signal(w.Inputs.data, self.iris) - self.assertIsNotNone(w.xv) - self.assertIsNotNone(w.yv) - self.assertIsNotNone(w.probabilities_grid) - self.assertTupleEqual( - (w.grid_size, w.grid_size), w.probabilities_grid.shape) - self.assertTupleEqual((w.grid_size, w.grid_size), w.xv.shape) - self.assertTupleEqual((w.grid_size, w.grid_size), w.yv.shape) - - # check that everything works fine when contours enabled/disabled - w.contours_enabled_checkbox.click() - - self.assertIsNotNone(w.xv) - self.assertIsNotNone(w.yv) - self.assertIsNotNone(w.probabilities_grid) - self.assertTupleEqual( - (w.grid_size, w.grid_size), w.probabilities_grid.shape) - self.assertTupleEqual((w.grid_size, w.grid_size), w.xv.shape) - self.assertTupleEqual((w.grid_size, w.grid_size), w.yv.shape) - - w.contours_enabled_checkbox.click() - - self.assertIsNotNone(w.xv) - self.assertIsNotNone(w.yv) - self.assertIsNotNone(w.probabilities_grid) - self.assertTupleEqual( - (w.grid_size, w.grid_size), w.probabilities_grid.shape) - self.assertTupleEqual((w.grid_size, w.grid_size), w.xv.shape) - self.assertTupleEqual((w.grid_size, w.grid_size), w.yv.shape) - - # when remove data - self.send_signal(w.Inputs.data, None) - - self.assertIsNone(w.xv) - self.assertIsNone(w.yv) - self.assertIsNone(w.probabilities_grid) - - def test_blur_grid(self): - w = self.widget - - self.send_signal(w.Inputs.data, self.iris) - # here we can check that 0.5 remains same - assert_array_equal(w.probabilities_grid == 0.5, - w.blur_grid(w.probabilities_grid) == 0.5) + self.assert_all_down() + self.assertTrue(w.Error.num_features.is_shown()) def test_select_data(self): - """ - Check if select data works properly - """ w = self.widget - self.send_signal(w.Inputs.data, self.iris) - selected_data = w.select_data() - self.assertEqual(len(selected_data.domain.attributes), 2) - self.assertIsNotNone(selected_data.domain.class_var) - self.assertEqual(len(selected_data.domain.metas), 1) - # meta with information about real cluster - self.assertEqual(len(selected_data), len(self.iris)) + w.select_data() + np.testing.assert_equal(w.selected_data.X, self.iris.X[:, :2]) + np.testing.assert_equal(w.selected_data.Y, [1] * 50 + [0] * 100) - # selected data none when one column only Nones - data = Table.from_numpy( - Domain([ContinuousVariable('a'), ContinuousVariable('b')], - DiscreteVariable('c', values=('a', 'b'))), - [[1, None], [1, None]], [0, 1] - ) - self.send_signal(w.Inputs.data, data) - selected_data = w.select_data() - self.assertIsNone(selected_data) + w.attr_x, w.attr_y = self.iris.domain[1:3] + w.select_data() + np.testing.assert_equal(w.selected_data.X, self.iris.X[:, 1:3]) + np.testing.assert_equal(w.selected_data.Y, [1] * 50 + [0] * 100) + self.assertIsNotNone(w.model) + self.assertIsNotNone(w.probabilities_grid) + # data without valid rows data = Table.from_numpy( Domain([ContinuousVariable('a'), ContinuousVariable('b')], DiscreteVariable('c', values=('a', 'b'))), - [[None, None], [None, None]], [0, 1] + [[1, None], [None, 1]], [0, 1] ) self.send_signal(w.Inputs.data, data) - selected_data = w.select_data() - self.assertIsNone(selected_data) - - def test_send_learner(self): - """ - Test if correct learner on output - """ - w = self.widget - - self.assertEqual(self.get_output(w.Outputs.learner), w.learner) - self.assertTrue(isinstance(self.get_output(w.Outputs.learner), w.LEARNER)) - - # set new learner - learner = TreeLearner - self.send_signal(w.Inputs.learner, learner()) - self.process_events() - self.assertEqual(self.get_output(w.Outputs.learner), w.learner) - self.assertTrue(isinstance(self.get_output(w.Outputs.learner), learner)) - - # back to default learner - self.send_signal(w.Inputs.learner, None) - self.process_events() - self.assertEqual(self.get_output(w.Outputs.learner), w.learner) - self.assertTrue(isinstance(self.get_output(w.Outputs.learner), w.LEARNER)) + w.select_data() + self.assertIsNone(w.selected_data) + self.assertIsNone(w.model) + self.assertIsNone(w.probabilities_grid) def test_update_model(self): - """ - Function check if correct model is on output - """ w = self.widget - - # when no data self.assertIsNone(w.model) self.assertIsNone(self.get_output(w.Outputs.model)) - # set data self.send_signal(w.Inputs.data, self.iris) self.assertIsNotNone(w.model) self.assertEqual(w.model, self.get_output(w.Outputs.model)) - # remove data self.send_signal(w.Inputs.data, None) self.assertIsNone(w.model) self.assertIsNone(self.get_output(w.Outputs.model)) - @unittest.skip("Travis fails: TimeoutError") def test_send_coefficients(self): - """ - Coefficients are only available if Logistic regression is used - """ w = self.widget - # none when no data (model not build) self.assertIsNone(self.get_output(w.Outputs.coefficients)) - # by default LogisticRegression so coefficients exists self.send_signal(w.Inputs.data, self.iris) - # to check correctness before degree is changed - num_coefficients = sum(i + 1 for i in range(w.degree + 1)) - self.assertEqual(len(self.get_output(w.Outputs.coefficients)), num_coefficients) - - # change degree - for j in range(1, 6): - w.degree_spin.setValue(j) - num_coefficients = sum(i + 1 for i in range(w.degree + 1)) + for w.degree in range(1, 6): + w._on_degree_changed() + self.assertEqual( + len(self.get_output(w.Outputs.coefficients)), + (w.degree + 1) * (w.degree + 2) // 2, + f"at degree={w.degree}") self.assertEqual( - len(self.get_output(w.Outputs.coefficients)), num_coefficients) + len(self.get_output(w.Outputs.data).domain.attributes), + (w.degree + 1) * (w.degree + 2) // 2 - 1, + f"at degree={w.degree}") # change learner which does not have coefficients learner = TreeLearner self.send_signal(w.Inputs.learner, learner()) self.assertIsNone(self.get_output(w.Outputs.coefficients)) + self.assertIsNotNone(self.get_output(w.Outputs.data)) - # remove learner self.send_signal(w.Inputs.learner, None) - - # to check correctness before degree is changed - num_coefficients = sum(i + 1 for i in range(w.degree + 1)) - self.assertEqual( - len(self.get_output(w.Outputs.coefficients)), num_coefficients) - - # change degree - for j in range(1, 6): - w.degree_spin.setValue(j) - num_coefficients = sum(i + 1 for i in range(w.degree + 1)) - self.assertEqual( - len(self.get_output(w.Outputs.coefficients)), num_coefficients) - - # manulay set LogisticRegression - self.send_signal(w.Inputs.learner, LogisticRegressionLearner()) - - # to check correctness before degree is changed - num_coefficients = sum(i + 1 for i in range(w.degree + 1)) - self.assertEqual(len(self.get_output(w.Outputs.coefficients)), num_coefficients) - - # change degree - for j in range(1, 6): - w.degree_spin.setValue(j) - num_coefficients = sum(i + 1 for i in range(w.degree + 1)) - self.assertEqual( - len(self.get_output(w.Outputs.coefficients)), num_coefficients) - - def test_send_data(self): - """ - Check data output signal - """ - w = self.widget - - self.assertIsNone(self.get_output(w.Outputs.data)) - - self.send_signal(w.Inputs.data, self.iris) - - # check correct number of attributes - for j in range(1, 6): - w.degree_spin.setValue(j) - self.assertEqual( - len(self.get_output(w.Outputs.data).domain.attributes), 2) - - self.assertEqual(len(self.get_output(w.Outputs.data).domain.metas), 1) - self.assertIsNotNone(self.get_output(w.Outputs.data).domain.class_var) - - # check again none - self.send_signal(w.Inputs.data, None) - self.assertIsNone(self.get_output(w.Outputs.data)) - - def test_send_report(self): - """ - Just test everything not crashes - """ - w = self.widget - - self.send_signal(w.Inputs.data, self.iris) - self.process_events(lambda: getattr(w, w.graph_name).svg()) - w.send_report() + for w.degree in range(1, 6): + w._on_degree_changed() + self.assertEqual(len(self.get_output(w.Outputs.coefficients)), + (w.degree + 1) * (w.degree + 2) // 2, + f"at degree={w.degree}") def test_bad_learner(self): - """ - Some learners on input might raise error. - GH-38 - """ w = self.widget self.assertFalse(w.Error.fitting_failed.is_shown()) @@ -551,51 +262,7 @@ def test_bad_learner(self): self.send_signal(w.Inputs.learner, learner) self.assertFalse(w.Error.fitting_failed.is_shown()) - def test_raise_no_classifier_error(self): - """ - Regression learner must raise error - """ - w = self.widget - - # linear regression learner is regression - should raise - learner = LinearRegressionLearner() - self.send_signal(w.Inputs.learner, learner) - self.assertTrue(w.Error.no_classifier.is_shown()) - - # make it empty to test if error disappear - self.send_signal(w.Inputs.learner, None) - self.assertFalse(w.Error.no_classifier.is_shown()) - - # test with some other learners - learner = LogisticRegressionLearner() - self.send_signal(w.Inputs.learner, learner) - self.assertFalse(w.Error.no_classifier.is_shown()) - - learner = TreeLearner() - self.send_signal(w.Inputs.learner, learner) - self.assertFalse(w.Error.no_classifier.is_shown()) - - learner = RandomForestLearner() - self.send_signal(w.Inputs.learner, learner) - self.assertFalse(w.Error.no_classifier.is_shown()) - - learner = SVMLearner() - self.send_signal(w.Inputs.learner, learner) - self.assertFalse(w.Error.no_classifier.is_shown()) - - def test_no_data_contour(self): - """ - Do not crash when there is no data and one want to draw contours. - GH-50 - """ - self.widget.contours_enabled_checkbox.click() - def test_sparse(self): - """ - Do not crash on sparse data. Convert used - sparse columns to numpy array. - GH-52 - """ w = self.widget def send_sparse_data(data): @@ -616,28 +283,61 @@ def send_sparse_data(data): def test_non_in_data(self): w = self.widget - data = Table.from_file("iris")[::15] - data.Y[:3] = np.nan + self.iris.Y[:10] = np.nan + self.iris.X[-4:, 0] = np.nan - self.send_signal(w.Inputs.data, data) + self.send_signal(w.Inputs.data, self.iris) + np.testing.assert_equal(w.selected_data.X, self.iris.X[:-4, :2]) - num_continuous_attributes = sum( - True for var in self.iris.domain.attributes - if isinstance(var, ContinuousVariable)) - self.assertEqual(w.cbx.count(), num_continuous_attributes) - self.assertEqual(w.cby.count(), num_continuous_attributes) - self.assertEqual( - w.target_class_combobox.count(), - len(self.iris.domain.class_var.values)) - self.assertEqual(w.cbx.currentText(), self.iris.domain[0].name) - self.assertEqual(w.cby.currentText(), self.iris.domain[1].name) - self.assertEqual( - w.target_class_combobox.currentText(), - self.iris.domain.class_var.values[0]) - - self.assertEqual(w.attr_x, self.iris.domain[0].name) - self.assertEqual(w.attr_y, self.iris.domain[1].name) - self.assertEqual(w.target_class, self.iris.domain.class_var.values[0]) + def test_same_variable(self): + w = self.widget + self.send_signal(w.Inputs.data, self.iris) + + assert w.selected_data is not None + assert w.attr_x is w.data.domain[0] + + w.attr_y = w.data.domain[0] + w._on_attr_changed() + self.assertIsNone(w.selected_data) + self.assertTrue(w.Error.same_variable.is_shown()) + + w.attr_y = w.data.domain[1] + w._on_attr_changed() + self.assertIsNotNone(w.selected_data) + self.assertFalse(w.Error.same_variable.is_shown()) + + self.send_signal(w.Inputs.data, None) + self.assertFalse(w.Error.same_variable.is_shown()) + + self.send_signal(w.Inputs.data, self.iris) + w.attr_y = w.data.domain[0] + w._on_attr_changed() + self.assertTrue(w.Error.same_variable.is_shown()) + + self.send_signal(w.Inputs.data, Table("housing")) + self.assertFalse(w.Error.same_variable.is_shown()) + + +class TestOWPolynomialClassification(WidgetTest): + # Tests that compute the probability grid and contours, so the code is + # run at least a few times + def setUp(self): + # type: OWPolynomialClassification + self.widget = self.create_widget(OWPolynomialClassification) + self.iris = Table.from_file("iris") + + def test_blur_grid(self): + w = self.widget + + self.send_signal(w.Inputs.data, self.iris) + # here we can check that 0.5 remains same + assert_array_equal(w.probabilities_grid == 0.5, + w.blur_grid(w.probabilities_grid) == 0.5) + + def test_send_report(self): + w = self.widget + self.send_signal(w.Inputs.data, self.iris) + w.send_report() if __name__ == "__main__": diff --git a/setup.py b/setup.py index 5903577..a633303 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ ] INSTALL_REQUIRES = [ - 'Orange3 >=3.27.0', + 'Orange3 >=3.27.1', 'BeautifulSoup4', 'numpy' ] diff --git a/tox.ini b/tox.ini index 6017af0..2484edc 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,7 @@ deps = pyqt5==5.12.* pyqtwebengine==5.12.* oldest: scikit-learn~=0.22.0 - oldest: orange3==3.27.0 + oldest: orange3==3.27.1 latest: git+git://github.com/biolab/orange3.git#egg=orange3 latest: git+git://github.com/biolab/orange-canvas-core.git#egg=orange-canvas-core latest: git+git://github.com/biolab/orange-widget-base.git#egg=orange-widget-base