def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: downPos = event.buttonDownPos(Qt.LeftButton) if not self.__tmpLine and self.__dragStartItem and \ (downPos - event.pos()).manhattanLength() > \ QApplication.instance().startDragDistance(): # Start a line drag line = QGraphicsLineItem(self) start = self.__dragStartItem.boundingRect().center() start = self.mapFromItem(self.__dragStartItem, start) line.setLine(start.x(), start.y(), event.pos().x(), event.pos().y()) pen = QPen(self.palette().color(QPalette.Foreground), 4) pen.setCapStyle(Qt.RoundCap) line.setPen(pen) line.show() self.__tmpLine = line if self.__tmpLine: # Update the temp line line = self.__tmpLine.line() line.setP2(event.pos()) self.__tmpLine.setLine(line) QGraphicsWidget.mouseMoveEvent(self, event)
def _draw_border(point_1, point_2, border_width, parent): pen = QPen(QColor(self.border_color)) pen.setCosmetic(True) pen.setWidth(border_width) line = QGraphicsLineItem(QLineF(point_1, point_2), parent) line.setPen(pen) return line
def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: downPos = event.buttonDownPos(Qt.LeftButton) if not self.__tmpLine and self.__dragStartItem and \ (downPos - event.pos()).manhattanLength() > \ QApplication.instance().startDragDistance(): # Start a line drag line = QGraphicsLineItem(self) start = self.__dragStartItem.boundingRect().center() start = self.mapFromItem(self.__dragStartItem, start) line.setLine(start.x(), start.y(), event.pos().x(), event.pos().y()) pen = QPen(Qt.black, 4) pen.setCapStyle(Qt.RoundCap) line.setPen(pen) line.show() self.__tmpLine = line if self.__tmpLine: # Update the temp line line = self.__tmpLine.line() line.setP2(event.pos()) self.__tmpLine.setLine(line) QGraphicsWidget.mouseMoveEvent(self, event)
class LinePlotViewBox(ViewBox): def __init__(self, graph, enable_menu=False): ViewBox.__init__(self, enableMenu=enable_menu) self.graph = graph self.setMouseMode(self.PanMode) pen = mkPen(LinePlotStyle.SELECTION_LINE_COLOR, width=LinePlotStyle.SELECTION_LINE_WIDTH) self.selection_line = QGraphicsLineItem() self.selection_line.setPen(pen) self.selection_line.setZValue(1e9) self.addItem(self.selection_line, ignoreBounds=True) def update_selection_line(self, button_down_pos, current_pos): p1 = self.childGroup.mapFromParent(button_down_pos) p2 = self.childGroup.mapFromParent(current_pos) self.selection_line.setLine(QLineF(p1, p2)) self.selection_line.resetTransform() self.selection_line.show() def mouseDragEvent(self, event, axis=None): if self.graph.state == SELECT and axis is None: event.accept() if event.button() == Qt.LeftButton: self.update_selection_line(event.buttonDownPos(), event.pos()) if event.isFinish(): self.selection_line.hide() p1 = self.childGroup.mapFromParent( event.buttonDownPos(event.button())) p2 = self.childGroup.mapFromParent(event.pos()) self.graph.select_by_line(p1, p2) else: self.update_selection_line( event.buttonDownPos(), event.pos()) elif self.graph.state == ZOOMING or self.graph.state == PANNING: event.ignore() super().mouseDragEvent(event, axis=axis) else: event.ignore() def mouseClickEvent(self, event): if event.button() == Qt.RightButton: self.autoRange() else: event.accept() self.graph.deselect_all()
def addLink(self, output, input): """ Add a link between `output` (:class:`OutputSignal`) and `input` (:class:`InputSignal`). """ if not compatible_channels(output, input): return if output not in self.source.output_channels(): raise ValueError("%r is not an output channel of %r" % \ (output, self.source)) if input not in self.sink.input_channels(): raise ValueError("%r is not an input channel of %r" % \ (input, self.sink)) if input.single: # Remove existing link if it exists. for s1, s2, _ in self.__links: if s2 == input: self.removeLink(s1, s2) line = QGraphicsLineItem(self) source_anchor = self.sourceNodeWidget.anchor(output) sink_anchor = self.sinkNodeWidget.anchor(input) source_pos = source_anchor.boundingRect().center() source_pos = self.mapFromItem(source_anchor, source_pos) sink_pos = sink_anchor.boundingRect().center() sink_pos = self.mapFromItem(sink_anchor, sink_pos) line.setLine(source_pos.x(), source_pos.y(), sink_pos.x(), sink_pos.y()) pen = QPen(self.palette().color(QPalette.Foreground), 4) pen.setCapStyle(Qt.RoundCap) line.setPen(pen) self.__links.append(_Link(output, input, line))
def addLink(self, output, input): """ Add a link between `output` (:class:`OutputSignal`) and `input` (:class:`InputSignal`). """ if not compatible_channels(output, input): return if output not in self.source.output_channels(): raise ValueError("%r is not an output channel of %r" % \ (output, self.source)) if input not in self.sink.input_channels(): raise ValueError("%r is not an input channel of %r" % \ (input, self.sink)) if input.single: # Remove existing link if it exists. for s1, s2, _ in self.__links: if s2 == input: self.removeLink(s1, s2) line = QGraphicsLineItem(self) source_anchor = self.sourceNodeWidget.anchor(output) sink_anchor = self.sinkNodeWidget.anchor(input) source_pos = source_anchor.boundingRect().center() source_pos = self.mapFromItem(source_anchor, source_pos) sink_pos = sink_anchor.boundingRect().center() sink_pos = self.mapFromItem(sink_anchor, sink_pos) line.setLine(source_pos.x(), source_pos.y(), sink_pos.x(), sink_pos.y()) pen = QPen(Qt.black, 4) pen.setCapStyle(Qt.RoundCap) line.setPen(pen) self.__links.append(_Link(output, input, line))
class OWNomogram(OWWidget): name = "Nomogram" description = " Nomograms for Visualization of Naive Bayesian" \ " and Logistic Regression Classifiers." icon = "icons/Nomogram.svg" priority = 2000 inputs = [("Classifier", Model, "set_classifier"), ("Data", Table, "set_instance")] MAX_N_ATTRS = 1000 POINT_SCALE = 0 ALIGN_LEFT = 0 ALIGN_ZERO = 1 ACCEPTABLE = (NaiveBayesModel, LogisticRegressionClassifier) settingsHandler = ClassValuesContextHandler() target_class_index = ContextSetting(0) normalize_probabilities = Setting(False) scale = Setting(1) display_index = Setting(1) n_attributes = Setting(10) sort_index = Setting(SortBy.ABSOLUTE) cont_feature_dim_index = Setting(0) graph_name = "scene" class Error(OWWidget.Error): invalid_classifier = Msg("Nomogram accepts only Naive Bayes and " "Logistic Regression classifiers.") def __init__(self): super().__init__() self.instances = None self.domain = None self.data = None self.classifier = None self.align = OWNomogram.ALIGN_ZERO self.log_odds_ratios = [] self.log_reg_coeffs = [] self.log_reg_coeffs_orig = [] self.log_reg_cont_data_extremes = [] self.p = None self.b0 = None self.points = [] self.feature_items = [] self.feature_marker_values = [] self.scale_back = lambda x: x self.scale_forth = lambda x: x self.nomogram = None self.nomogram_main = None self.vertical_line = None self.hidden_vertical_line = None self.old_target_class_index = self.target_class_index self.markers_set = False self.repaint = False # GUI box = gui.vBox(self.controlArea, "Target class") self.class_combo = gui.comboBox(box, self, "target_class_index", callback=self._class_combo_changed, contentsLength=12) self.norm_check = gui.checkBox( box, self, "normalize_probabilities", "Normalize probabilities", hidden=True, callback=self._norm_check_changed, tooltip="For multiclass data 1 vs. all probabilities do not" " sum to 1 and therefore could be normalized.") self.scale_radio = gui.radioButtons( self.controlArea, self, "scale", ["Point scale", "Log odds ratios"], box="Scale", callback=self._radio_button_changed) box = gui.vBox(self.controlArea, "Display features") grid = QGridLayout() self.display_radio = gui.radioButtonsInBox( box, self, "display_index", [], orientation=grid, callback=self._display_radio_button_changed) radio_all = gui.appendRadioButton(self.display_radio, "All:", addToLayout=False) radio_best = gui.appendRadioButton(self.display_radio, "Best ranked:", addToLayout=False) spin_box = gui.hBox(None, margin=0) self.n_spin = gui.spin(spin_box, self, "n_attributes", 1, self.MAX_N_ATTRS, label=" ", controlWidth=60, callback=self._n_spin_changed) grid.addWidget(radio_all, 1, 1) grid.addWidget(radio_best, 2, 1) grid.addWidget(spin_box, 2, 2) self.sort_combo = gui.comboBox(box, self, "sort_index", label="Sort by: ", items=SortBy.items(), orientation=Qt.Horizontal, callback=self._sort_combo_changed) self.cont_feature_dim_combo = gui.comboBox( box, self, "cont_feature_dim_index", label="Continuous features: ", items=["1D projection", "2D curve"], orientation=Qt.Horizontal, callback=self._cont_feature_dim_combo_changed) gui.rubber(self.controlArea) self.scene = QGraphicsScene() self.view = QGraphicsView( self.scene, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, renderHints=QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform, alignment=Qt.AlignLeft) self.view.viewport().installEventFilter(self) self.view.viewport().setMinimumWidth(300) self.view.sizeHint = lambda: QSize(600, 500) self.mainArea.layout().addWidget(self.view) def _class_combo_changed(self): values = [item.dot.value for item in self.feature_items] self.feature_marker_values = self.scale_back(values) coeffs = [ np.nan_to_num(p[self.target_class_index] / p[self.old_target_class_index]) for p in self.points ] points = [p[self.old_target_class_index] for p in self.points] self.feature_marker_values = [ self.get_points_from_coeffs(v, c, p) for (v, c, p) in zip(self.feature_marker_values, coeffs, points) ] self.update_scene() self.old_target_class_index = self.target_class_index def _norm_check_changed(self): values = [item.dot.value for item in self.feature_items] self.feature_marker_values = self.scale_back(values) self.update_scene() def _radio_button_changed(self): values = [item.dot.value for item in self.feature_items] self.feature_marker_values = self.scale_back(values) self.update_scene() def _display_radio_button_changed(self): self.__hide_attrs(self.n_attributes if self.display_index else None) def _n_spin_changed(self): self.display_index = 1 self.__hide_attrs(self.n_attributes) def __hide_attrs(self, n_show): if self.nomogram_main is None: return self.nomogram_main.hide(n_show) if self.vertical_line: x = self.vertical_line.line().x1() y = self.nomogram_main.layout.preferredHeight() + 30 self.vertical_line.setLine(x, -6, x, y) self.hidden_vertical_line.setLine(x, -6, x, y) rect = QRectF(self.scene.sceneRect().x(), self.scene.sceneRect().y(), self.scene.itemsBoundingRect().width(), self.nomogram.preferredSize().height()) self.scene.setSceneRect(rect.adjusted(0, 0, 70, 70)) def _sort_combo_changed(self): if self.nomogram_main is None: return self.nomogram_main.hide(None) self.nomogram_main.sort(self.sort_index) self.__hide_attrs(self.n_attributes if self.display_index else None) def _cont_feature_dim_combo_changed(self): values = [item.dot.value for item in self.feature_items] self.feature_marker_values = self.scale_back(values) self.update_scene() def eventFilter(self, obj, event): if obj is self.view.viewport() and event.type() == QEvent.Resize: self.repaint = True values = [item.dot.value for item in self.feature_items] self.feature_marker_values = self.scale_back(values) self.update_scene() return super().eventFilter(obj, event) def update_controls(self): self.class_combo.clear() self.norm_check.setHidden(True) self.cont_feature_dim_combo.setEnabled(True) if self.domain: self.class_combo.addItems(self.domain.class_vars[0].values) if len(self.domain.attributes) > self.MAX_N_ATTRS: self.display_index = 1 if len(self.domain.class_vars[0].values) > 2: self.norm_check.setHidden(False) if not self.domain.has_continuous_attributes(): self.cont_feature_dim_combo.setEnabled(False) self.cont_feature_dim_index = 0 model = self.sort_combo.model() item = model.item(SortBy.POSITIVE) item.setFlags(item.flags() | Qt.ItemIsEnabled) item = model.item(SortBy.NEGATIVE) item.setFlags(item.flags() | Qt.ItemIsEnabled) self.align = OWNomogram.ALIGN_ZERO if self.classifier and isinstance(self.classifier, LogisticRegressionClassifier): self.align = OWNomogram.ALIGN_LEFT item = model.item(SortBy.POSITIVE) item.setFlags(item.flags() & ~Qt.ItemIsEnabled) item = model.item(SortBy.NEGATIVE) item.setFlags(item.flags() & ~Qt.ItemIsEnabled) if self.sort_index in (SortBy.POSITIVE, SortBy.POSITIVE): self.sort_index = SortBy.NO_SORTING def set_instance(self, data): self.instances = data self.feature_marker_values = [] self.set_feature_marker_values() def set_classifier(self, classifier): self.closeContext() self.classifier = classifier self.Error.clear() if self.classifier and not isinstance(self.classifier, self.ACCEPTABLE): self.Error.invalid_classifier() self.classifier = None self.domain = self.classifier.domain if self.classifier else None self.data = None self.calculate_log_odds_ratios() self.calculate_log_reg_coefficients() self.update_controls() self.target_class_index = 0 self.openContext(self.domain and self.domain.class_var) self.points = self.log_odds_ratios or self.log_reg_coeffs self.feature_marker_values = [] self.old_target_class_index = self.target_class_index self.update_scene() def calculate_log_odds_ratios(self): self.log_odds_ratios = [] self.p = None if self.classifier is None or self.domain is None: return if not isinstance(self.classifier, NaiveBayesModel): return log_cont_prob = self.classifier.log_cont_prob class_prob = self.classifier.class_prob for i in range(len(self.domain.attributes)): ca = np.exp(log_cont_prob[i]) * class_prob[:, None] _or = (ca / (1 - ca)) / (class_prob / (1 - class_prob))[:, None] self.log_odds_ratios.append(np.log(_or)) self.p = class_prob def calculate_log_reg_coefficients(self): self.log_reg_coeffs = [] self.log_reg_cont_data_extremes = [] self.b0 = None if self.classifier is None or self.domain is None: return if not isinstance(self.classifier, LogisticRegressionClassifier): return self.domain = self.reconstruct_domain(self.classifier.original_domain, self.domain) self.data = self.classifier.original_data.transform(self.domain) attrs, ranges, start = self.domain.attributes, [], 0 for attr in attrs: stop = start + len(attr.values) if attr.is_discrete else start + 1 ranges.append(slice(start, stop)) start = stop self.b0 = self.classifier.intercept coeffs = self.classifier.coefficients if len(self.domain.class_var.values) == 2: self.b0 = np.hstack((self.b0 * (-1), self.b0)) coeffs = np.vstack((coeffs * (-1), coeffs)) self.log_reg_coeffs = [coeffs[:, ranges[i]] for i in range(len(attrs))] self.log_reg_coeffs_orig = self.log_reg_coeffs.copy() min_values = nanmin(self.data.X, axis=0) max_values = nanmax(self.data.X, axis=0) for i, min_t, max_t in zip(range(len(self.log_reg_coeffs)), min_values, max_values): if self.log_reg_coeffs[i].shape[1] == 1: coef = self.log_reg_coeffs[i] self.log_reg_coeffs[i] = np.hstack( (coef * min_t, coef * max_t)) self.log_reg_cont_data_extremes.append( [sorted([min_t, max_t], reverse=(c < 0)) for c in coef]) else: self.log_reg_cont_data_extremes.append([None]) def update_scene(self): if not self.repaint: return self.clear_scene() if self.domain is None or not len(self.points[0]): return name_items = [ QGraphicsTextItem(a.name) for a in self.domain.attributes ] point_text = QGraphicsTextItem("Points") probs_text = QGraphicsTextItem("Probabilities (%)") all_items = name_items + [point_text, probs_text] name_offset = -max(t.boundingRect().width() for t in all_items) - 50 w = self.view.viewport().rect().width() max_width = w + name_offset - 100 points = [pts[self.target_class_index] for pts in self.points] minimums = [min(p) for p in points] if self.align == OWNomogram.ALIGN_LEFT: points = [p - m for m, p in zip(minimums, points)] max_ = np.nan_to_num(max(max(abs(p)) for p in points)) d = 100 / max_ if max_ else 1 if self.scale == OWNomogram.POINT_SCALE: points = [p * d for p in points] if self.scale == OWNomogram.POINT_SCALE and \ self.align == OWNomogram.ALIGN_LEFT: self.scale_back = lambda x: [ p / d + m for m, p in zip(minimums, x) ] self.scale_forth = lambda x: [(p - m) * d for m, p in zip(minimums, x)] if self.scale == OWNomogram.POINT_SCALE and \ self.align != OWNomogram.ALIGN_LEFT: self.scale_back = lambda x: [p / d for p in x] self.scale_forth = lambda x: [p * d for p in x] if self.scale != OWNomogram.POINT_SCALE and \ self.align == OWNomogram.ALIGN_LEFT: self.scale_back = lambda x: [p + m for m, p in zip(minimums, x)] self.scale_forth = lambda x: [p - m for m, p in zip(minimums, x)] if self.scale != OWNomogram.POINT_SCALE and \ self.align != OWNomogram.ALIGN_LEFT: self.scale_back = lambda x: x self.scale_forth = lambda x: x point_item, nomogram_head = self.create_main_nomogram( name_items, points, max_width, point_text, name_offset) probs_item, nomogram_foot = self.create_footer_nomogram( probs_text, d, minimums, max_width, name_offset) for item in self.feature_items: item.dot.point_dot = point_item.dot item.dot.probs_dot = probs_item.dot item.dot.vertical_line = self.hidden_vertical_line self.nomogram = nomogram = NomogramItem() nomogram.add_items([nomogram_head, self.nomogram_main, nomogram_foot]) self.scene.addItem(nomogram) self.set_feature_marker_values() rect = QRectF(self.scene.itemsBoundingRect().x(), self.scene.itemsBoundingRect().y(), self.scene.itemsBoundingRect().width(), self.nomogram.preferredSize().height()) self.scene.setSceneRect(rect.adjusted(0, 0, 70, 70)) def create_main_nomogram(self, name_items, points, max_width, point_text, name_offset): cls_index = self.target_class_index min_p = min(min(p) for p in points) max_p = max(max(p) for p in points) values = self.get_ruler_values(min_p, max_p, max_width) min_p, max_p = min(values), max(values) diff_ = np.nan_to_num(max_p - min_p) scale_x = max_width / diff_ if diff_ else max_width nomogram_header = NomogramItem() point_item = RulerItem(point_text, values, scale_x, name_offset, -scale_x * min_p) point_item.setPreferredSize(point_item.preferredWidth(), 35) nomogram_header.add_items([point_item]) self.nomogram_main = SortableNomogramItem() cont_feature_item_class = ContinuousFeature2DItem if \ self.cont_feature_dim_index else ContinuousFeatureItem self.feature_items = [ DiscreteFeatureItem(name_items[i], [val for val in att.values], points[i], scale_x, name_offset, -scale_x * min_p, self.points[i][cls_index]) if att.is_discrete else cont_feature_item_class( name_items[i], self.log_reg_cont_data_extremes[i][cls_index], self.get_ruler_values( np.min(points[i]), np.max(points[i]), scale_x * (np.max(points[i]) - np.min(points[i])), False), scale_x, name_offset, -scale_x * min_p, self.log_reg_coeffs_orig[i][cls_index][0]) for i, att in enumerate(self.domain.attributes) ] self.nomogram_main.add_items( self.feature_items, self.sort_index, self.n_attributes if self.display_index else None) x = -scale_x * min_p y = self.nomogram_main.layout.preferredHeight() + 30 self.vertical_line = QGraphicsLineItem(x, -6, x, y) self.vertical_line.setPen(QPen(Qt.DotLine)) self.vertical_line.setParentItem(point_item) self.hidden_vertical_line = QGraphicsLineItem(x, -6, x, y) pen = QPen(Qt.DashLine) pen.setBrush(QColor(Qt.red)) self.hidden_vertical_line.setPen(pen) self.hidden_vertical_line.setParentItem(point_item) return point_item, nomogram_header def create_footer_nomogram(self, probs_text, d, minimums, max_width, name_offset): eps, d_ = 0.05, 1 k = -np.log(self.p / (1 - self.p)) if self.p is not None else -self.b0 min_sum = k[self.target_class_index] - np.log((1 - eps) / eps) max_sum = k[self.target_class_index] - np.log(eps / (1 - eps)) if self.align == OWNomogram.ALIGN_LEFT: max_sum = max_sum - sum(minimums) min_sum = min_sum - sum(minimums) for i in range(len(k)): k[i] = k[i] - sum( [min(q) for q in [p[i] for p in self.points]]) if self.scale == OWNomogram.POINT_SCALE: min_sum *= d max_sum *= d d_ = d values = self.get_ruler_values(min_sum, max_sum, max_width) min_sum, max_sum = min(values), max(values) diff_ = np.nan_to_num(max_sum - min_sum) scale_x = max_width / diff_ if diff_ else max_width cls_var, cls_index = self.domain.class_var, self.target_class_index nomogram_footer = NomogramItem() def get_normalized_probabilities(val): if not self.normalize_probabilities: return 1 / (1 + np.exp(k[cls_index] - val / d_)) totals = self.__get_totals_for_class_values(minimums) p_sum = np.sum(1 / (1 + np.exp(k - totals / d_))) return 1 / (1 + np.exp(k[cls_index] - val / d_)) / p_sum def get_points(prob): if not self.normalize_probabilities: return (k[cls_index] - np.log(1 / prob - 1)) * d_ totals = self.__get_totals_for_class_values(minimums) p_sum = np.sum(1 / (1 + np.exp(k - totals / d_))) return (k[cls_index] - np.log(1 / (prob * p_sum) - 1)) * d_ self.markers_set = False probs_item = ProbabilitiesRulerItem( probs_text, values, scale_x, name_offset, -scale_x * min_sum, get_points=get_points, title="{}='{}'".format(cls_var.name, cls_var.values[cls_index]), get_probabilities=get_normalized_probabilities) self.markers_set = True nomogram_footer.add_items([probs_item]) return probs_item, nomogram_footer def __get_totals_for_class_values(self, minimums): cls_index = self.target_class_index marker_values = [item.dot.value for item in self.feature_items] if not self.markers_set: marker_values = self.scale_forth(marker_values) totals = np.empty(len(self.domain.class_var.values)) totals[cls_index] = sum(marker_values) marker_values = self.scale_back(marker_values) for i in range(len(self.domain.class_var.values)): if i == cls_index: continue coeffs = [np.nan_to_num(p[i] / p[cls_index]) for p in self.points] points = [p[cls_index] for p in self.points] total = sum([ self.get_points_from_coeffs(v, c, p) for (v, c, p) in zip(marker_values, coeffs, points) ]) if self.align == OWNomogram.ALIGN_LEFT: points = [p - m for m, p in zip(minimums, points)] total -= sum([min(p) for p in [p[i] for p in self.points]]) d = 100 / max(max(abs(p)) for p in points) if self.scale == OWNomogram.POINT_SCALE: total *= d totals[i] = total return totals def set_feature_marker_values(self): if not (len(self.points) and len(self.feature_items)): return if not len(self.feature_marker_values): self._init_feature_marker_values() self.feature_marker_values = self.scale_forth( self.feature_marker_values) item = self.feature_items[0] for i, item in enumerate(self.feature_items): item.dot.move_to_val(self.feature_marker_values[i]) item.dot.probs_dot.move_to_sum() def _init_feature_marker_values(self): self.feature_marker_values = [] cls_index = self.target_class_index instances = Table(self.domain, self.instances) \ if self.instances else None for i, attr in enumerate(self.domain.attributes): value, feature_val = 0, None if len(self.log_reg_coeffs): if attr.is_discrete: ind, n = unique(self.data.X[:, i], return_counts=True) feature_val = np.nan_to_num(ind[np.argmax(n)]) else: feature_val = mean(self.data.X[:, i]) inst_in_dom = instances and attr in instances.domain if inst_in_dom and not np.isnan(instances[0][attr]): feature_val = instances[0][attr] if feature_val is not None: value = self.points[i][cls_index][int(feature_val)] \ if attr.is_discrete else \ self.log_reg_coeffs_orig[i][cls_index][0] * feature_val self.feature_marker_values.append(value) def clear_scene(self): self.feature_items = [] self.scale_back = lambda x: x self.scale_forth = lambda x: x self.nomogram = None self.nomogram_main = None self.vertical_line = None self.hidden_vertical_line = None self.scene.clear() def send_report(self): self.report_plot() @staticmethod def reconstruct_domain(original, preprocessed): # abuse dict to make "in" comparisons faster attrs = OrderedDict() for attr in preprocessed.attributes: cv = attr._compute_value.variable._compute_value var = cv.variable if cv else original[attr.name] if var in attrs: # the reason for OrderedDict continue attrs[var] = None # we only need keys attrs = list(attrs.keys()) return Domain(attrs, original.class_var, original.metas) @staticmethod def get_ruler_values(start, stop, max_width, round_to_nearest=True): if max_width == 0: return [0] diff = np.nan_to_num((stop - start) / max_width) if diff <= 0: return [0] decimals = int(np.floor(np.log10(diff))) if diff > 4 * pow(10, decimals): step = 5 * pow(10, decimals + 2) elif diff > 2 * pow(10, decimals): step = 2 * pow(10, decimals + 2) elif diff > 1 * pow(10, decimals): step = 1 * pow(10, decimals + 2) else: step = 5 * pow(10, decimals + 1) round_by = int(-np.floor(np.log10(step))) r = start % step if not round_to_nearest: _range = np.arange(start + step, stop + r, step) - r start, stop = np.floor(start * 100) / 100, np.ceil( stop * 100) / 100 return np.round(np.hstack((start, _range, stop)), 2) return np.round(np.arange(start, stop + r + step, step) - r, round_by) @staticmethod def get_points_from_coeffs(current_value, coefficients, possible_values): if any(np.isnan(possible_values)): return 0 indices = np.argsort(possible_values) sorted_values = possible_values[indices] sorted_coefficients = coefficients[indices] for i, val in enumerate(sorted_values): if current_value < val: break diff = sorted_values[i] - sorted_values[i - 1] k = 0 if diff < 1e-6 else (sorted_values[i] - current_value) / \ (sorted_values[i] - sorted_values[i - 1]) return sorted_coefficients[i - 1] * sorted_values[i - 1] * k + \ sorted_coefficients[i] * sorted_values[i] * (1 - k)
def __init__(self, name, data_extremes, values, scale, name_offset, offset, coef): super().__init__() data_start, data_stop = data_extremes[0], data_extremes[1] self.name = name.toPlainText() self.diff = (data_stop - data_start) * coef labels = [ str( np.round( data_start + (data_stop - data_start) * i / (self.n_tck - 1), 1)) for i in range(self.n_tck) ] # leading label font = name.document().defaultFont() name.setFont(font) name.setPos(name_offset, -10) name.setParentItem(self) # labels ascending = data_start < data_stop y_start, y_stop = (self.y_diff, 0) if ascending else (0, self.y_diff) for i in range(self.n_tck): text = QGraphicsSimpleTextItem(labels[i]) w = text.boundingRect().width() y = y_start + (y_stop - y_start) / (self.n_tck - 1) * i text.setPos(-5 - w, y - 8) text.setParentItem(self) tick = QGraphicsLineItem(-2, y, 2, y) tick.setParentItem(self) # prediction marker self.dot = Continuous2DMovableDotItem(self.dot_r, scale, offset, values[0], values[-1], y_start, y_stop) self.dot.tooltip_labels = labels self.dot.tooltip_values = values self.dot.setParentItem(self) h_line = QGraphicsLineItem(values[0] * scale + offset, self.y_diff / 2, values[-1] * scale + offset, self.y_diff / 2) pen = QPen(Qt.DashLine) pen.setBrush(QColor(Qt.red)) h_line.setPen(pen) h_line.setParentItem(self) self.dot.horizontal_line = h_line # line line = QGraphicsLineItem(values[0] * scale + offset, y_start, values[-1] * scale + offset, y_stop) line.setParentItem(self) # ticks for value in values: diff_ = np.nan_to_num(values[-1] - values[0]) k = (value - values[0]) / diff_ if diff_ else 0 y_tick = (y_stop - y_start) * k + y_start - self.tick_height / 2 x_tick = value * scale - self.tick_width / 2 + offset tick = QGraphicsRectItem(x_tick, y_tick, self.tick_width, self.tick_height) tick.setBrush(QColor(Qt.black)) tick.setParentItem(self) # rect rect = QGraphicsRectItem(values[0] * scale + offset, -self.y_diff * 0.125, values[-1] * scale + offset, self.y_diff * 1.25) pen = QPen(Qt.DotLine) pen.setBrush(QColor(50, 150, 200, 255)) rect.setPen(pen) rect.setParentItem(self) self.setPreferredSize(self.preferredWidth(), self.y_diff * 1.5)
class LinePlotViewBox(ViewBox): selection_changed = Signal(np.ndarray) def __init__(self): super().__init__(enableMenu=False) self._profile_items = None self._can_select = True self._graph_state = SELECT self.setMouseMode(self.PanMode) pen = mkPen(LinePlotStyle.SELECTION_LINE_COLOR, width=LinePlotStyle.SELECTION_LINE_WIDTH) self.selection_line = QGraphicsLineItem() self.selection_line.setPen(pen) self.selection_line.setZValue(1e9) self.addItem(self.selection_line, ignoreBounds=True) def update_selection_line(self, button_down_pos, current_pos): p1 = self.childGroup.mapFromParent(button_down_pos) p2 = self.childGroup.mapFromParent(current_pos) self.selection_line.setLine(QLineF(p1, p2)) self.selection_line.resetTransform() self.selection_line.show() def set_graph_state(self, state): self._graph_state = state def enable_selection(self, enable): self._can_select = enable def get_selected(self, p1, p2): if self._profile_items is None: return np.array(False) return line_intersects_profiles(np.array([p1.x(), p1.y()]), np.array([p2.x(), p2.y()]), self._profile_items) def add_profiles(self, y): if sp.issparse(y): y = y.todense() self._profile_items = np.array( [np.vstack((np.full((1, y.shape[0]), i + 1), y[:, i].flatten())).T for i in range(y.shape[1])]) def remove_profiles(self): self._profile_items = None def mouseDragEvent(self, ev, axis=None): if self._graph_state == SELECT and axis is None and self._can_select: ev.accept() if ev.button() == Qt.LeftButton: self.update_selection_line(ev.buttonDownPos(), ev.pos()) if ev.isFinish(): self.selection_line.hide() p1 = self.childGroup.mapFromParent( ev.buttonDownPos(ev.button())) p2 = self.childGroup.mapFromParent(ev.pos()) self.selection_changed.emit(self.get_selected(p1, p2)) elif self._graph_state == ZOOMING or self._graph_state == PANNING: ev.ignore() super().mouseDragEvent(ev, axis=axis) else: ev.ignore() def mouseClickEvent(self, ev): if ev.button() == Qt.RightButton: self.autoRange() self.enableAutoRange() else: ev.accept() self.selection_changed.emit(np.array(False)) def reset(self): self._profile_items = None self._can_select = True self._graph_state = SELECT
class OWNomogram(OWWidget): name = "Nomogram" description = " Nomograms for Visualization of Naive Bayesian" \ " and Logistic Regression Classifiers." icon = "icons/Nomogram.svg" priority = 2000 class Inputs: classifier = Input("Classifier", Model) data = Input("Data", Table) MAX_N_ATTRS = 1000 POINT_SCALE = 0 ALIGN_LEFT = 0 ALIGN_ZERO = 1 ACCEPTABLE = (NaiveBayesModel, LogisticRegressionClassifier) settingsHandler = ClassValuesContextHandler() target_class_index = ContextSetting(0) normalize_probabilities = Setting(False) scale = Setting(1) display_index = Setting(1) n_attributes = Setting(10) sort_index = Setting(SortBy.ABSOLUTE) cont_feature_dim_index = Setting(0) graph_name = "scene" class Error(OWWidget.Error): invalid_classifier = Msg("Nomogram accepts only Naive Bayes and " "Logistic Regression classifiers.") def __init__(self): super().__init__() self.instances = None self.domain = None self.data = None self.classifier = None self.align = OWNomogram.ALIGN_ZERO self.log_odds_ratios = [] self.log_reg_coeffs = [] self.log_reg_coeffs_orig = [] self.log_reg_cont_data_extremes = [] self.p = None self.b0 = None self.points = [] self.feature_items = {} self.feature_marker_values = [] self.scale_marker_values = lambda x: x self.nomogram_main = None self.vertical_line = None self.hidden_vertical_line = None self.old_target_class_index = self.target_class_index self.repaint = False # GUI box = gui.vBox(self.controlArea, "Target class") self.class_combo = gui.comboBox( box, self, "target_class_index", callback=self._class_combo_changed, contentsLength=12) self.norm_check = gui.checkBox( box, self, "normalize_probabilities", "Normalize probabilities", hidden=True, callback=self.update_scene, tooltip="For multiclass data 1 vs. all probabilities do not" " sum to 1 and therefore could be normalized.") self.scale_radio = gui.radioButtons( self.controlArea, self, "scale", ["Point scale", "Log odds ratios"], box="Scale", callback=self.update_scene) box = gui.vBox(self.controlArea, "Display features") grid = QGridLayout() radio_group = gui.radioButtonsInBox( box, self, "display_index", [], orientation=grid, callback=self.update_scene) radio_all = gui.appendRadioButton( radio_group, "All", addToLayout=False) radio_best = gui.appendRadioButton( radio_group, "Best ranked:", addToLayout=False) spin_box = gui.hBox(None, margin=0) self.n_spin = gui.spin( spin_box, self, "n_attributes", 1, self.MAX_N_ATTRS, label=" ", controlWidth=60, callback=self._n_spin_changed) grid.addWidget(radio_all, 1, 1) grid.addWidget(radio_best, 2, 1) grid.addWidget(spin_box, 2, 2) self.sort_combo = gui.comboBox( box, self, "sort_index", label="Rank by:", items=SortBy.items(), orientation=Qt.Horizontal, callback=self.update_scene) self.cont_feature_dim_combo = gui.comboBox( box, self, "cont_feature_dim_index", label="Numeric features: ", items=["1D projection", "2D curve"], orientation=Qt.Horizontal, callback=self.update_scene) gui.rubber(self.controlArea) class _GraphicsView(QGraphicsView): def __init__(self, scene, parent, **kwargs): for k, v in dict(verticalScrollBarPolicy=Qt.ScrollBarAlwaysOff, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, viewportUpdateMode=QGraphicsView.BoundingRectViewportUpdate, renderHints=(QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform), alignment=(Qt.AlignTop | Qt.AlignLeft), sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)).items(): kwargs.setdefault(k, v) super().__init__(scene, parent, **kwargs) class GraphicsView(_GraphicsView): def __init__(self, scene, parent): super().__init__(scene, parent, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, styleSheet='QGraphicsView {background: white}') self.viewport().setMinimumWidth(300) # XXX: This prevents some tests failing self._is_resizing = False w = self def resizeEvent(self, resizeEvent): # Recompute main scene on window width change if resizeEvent.size().width() != resizeEvent.oldSize().width(): self._is_resizing = True self.w.update_scene() self._is_resizing = False return super().resizeEvent(resizeEvent) def is_resizing(self): return self._is_resizing def sizeHint(self): return QSize(400, 200) class FixedSizeGraphicsView(_GraphicsView): def __init__(self, scene, parent): super().__init__(scene, parent, sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)) def sizeHint(self): return QSize(400, 85) scene = self.scene = QGraphicsScene(self) top_view = self.top_view = FixedSizeGraphicsView(scene, self) mid_view = self.view = GraphicsView(scene, self) bottom_view = self.bottom_view = FixedSizeGraphicsView(scene, self) for view in (top_view, mid_view, bottom_view): self.mainArea.layout().addWidget(view) def _class_combo_changed(self): with np.errstate(invalid='ignore'): coeffs = [np.nan_to_num(p[self.target_class_index] / p[self.old_target_class_index]) for p in self.points] points = [p[self.old_target_class_index] for p in self.points] self.feature_marker_values = [ self.get_points_from_coeffs(v, c, p) for (v, c, p) in zip(self.feature_marker_values, coeffs, points)] self.feature_marker_values = np.asarray(self.feature_marker_values) self.update_scene() self.old_target_class_index = self.target_class_index def _n_spin_changed(self): self.display_index = 1 self.update_scene() def update_controls(self): self.class_combo.clear() self.norm_check.setHidden(True) self.cont_feature_dim_combo.setEnabled(True) if self.domain is not None: self.class_combo.addItems(self.domain.class_vars[0].values) if len(self.domain.attributes) > self.MAX_N_ATTRS: self.display_index = 1 if len(self.domain.class_vars[0].values) > 2: self.norm_check.setHidden(False) if not self.domain.has_continuous_attributes(): self.cont_feature_dim_combo.setEnabled(False) self.cont_feature_dim_index = 0 model = self.sort_combo.model() item = model.item(SortBy.POSITIVE) item.setFlags(item.flags() | Qt.ItemIsEnabled) item = model.item(SortBy.NEGATIVE) item.setFlags(item.flags() | Qt.ItemIsEnabled) self.align = OWNomogram.ALIGN_ZERO if self.classifier and isinstance(self.classifier, LogisticRegressionClassifier): self.align = OWNomogram.ALIGN_LEFT @Inputs.data def set_data(self, data): self.instances = data self.feature_marker_values = [] self.set_feature_marker_values() self.update_scene() @Inputs.classifier def set_classifier(self, classifier): self.closeContext() self.classifier = classifier self.Error.clear() if self.classifier and not isinstance(self.classifier, self.ACCEPTABLE): self.Error.invalid_classifier() self.classifier = None self.domain = self.classifier.domain if self.classifier else None self.data = None self.calculate_log_odds_ratios() self.calculate_log_reg_coefficients() self.update_controls() self.target_class_index = 0 self.openContext(self.domain.class_var if self.domain is not None else None) self.points = self.log_odds_ratios or self.log_reg_coeffs self.feature_marker_values = [] self.old_target_class_index = self.target_class_index self.update_scene() def calculate_log_odds_ratios(self): self.log_odds_ratios = [] self.p = None if self.classifier is None or self.domain is None: return if not isinstance(self.classifier, NaiveBayesModel): return log_cont_prob = self.classifier.log_cont_prob class_prob = self.classifier.class_prob for i in range(len(self.domain.attributes)): ca = np.exp(log_cont_prob[i]) * class_prob[:, None] _or = (ca / (1 - ca)) / (class_prob / (1 - class_prob))[:, None] self.log_odds_ratios.append(np.log(_or)) self.p = class_prob def calculate_log_reg_coefficients(self): self.log_reg_coeffs = [] self.log_reg_cont_data_extremes = [] self.b0 = None if self.classifier is None or self.domain is None: return if not isinstance(self.classifier, LogisticRegressionClassifier): return self.domain = self.reconstruct_domain(self.classifier.original_domain, self.domain) self.data = self.classifier.original_data.transform(self.domain) attrs, ranges, start = self.domain.attributes, [], 0 for attr in attrs: stop = start + len(attr.values) if attr.is_discrete else start + 1 ranges.append(slice(start, stop)) start = stop self.b0 = self.classifier.intercept coeffs = self.classifier.coefficients if len(self.domain.class_var.values) == 2: self.b0 = np.hstack((self.b0 * (-1), self.b0)) coeffs = np.vstack((coeffs * (-1), coeffs)) self.log_reg_coeffs = [coeffs[:, ranges[i]] for i in range(len(attrs))] self.log_reg_coeffs_orig = self.log_reg_coeffs.copy() min_values = nanmin(self.data.X, axis=0) max_values = nanmax(self.data.X, axis=0) for i, min_t, max_t in zip(range(len(self.log_reg_coeffs)), min_values, max_values): if self.log_reg_coeffs[i].shape[1] == 1: coef = self.log_reg_coeffs[i] self.log_reg_coeffs[i] = np.hstack((coef * min_t, coef * max_t)) self.log_reg_cont_data_extremes.append( [sorted([min_t, max_t], reverse=(c < 0)) for c in coef]) else: self.log_reg_cont_data_extremes.append([None]) def update_scene(self): self.clear_scene() if self.domain is None or not len(self.points[0]): return n_attrs = self.n_attributes if self.display_index else int(1e10) attr_inds, attributes = zip(*self.get_ordered_attributes()[:n_attrs]) name_items = [QGraphicsTextItem(attr.name) for attr in attributes] point_text = QGraphicsTextItem("Points") probs_text = QGraphicsTextItem("Probabilities (%)") all_items = name_items + [point_text, probs_text] name_offset = -max(t.boundingRect().width() for t in all_items) - 10 w = self.view.viewport().rect().width() max_width = w + name_offset - 30 points = [self.points[i][self.target_class_index] for i in attr_inds] if self.align == OWNomogram.ALIGN_LEFT: points = [p - p.min() for p in points] max_ = np.nan_to_num(max(max(abs(p)) for p in points)) d = 100 / max_ if max_ else 1 minimums = [p[self.target_class_index].min() for p in self.points] if self.scale == OWNomogram.POINT_SCALE: points = [p * d for p in points] if self.align == OWNomogram.ALIGN_LEFT: self.scale_marker_values = lambda x: (x - minimums) * d else: self.scale_marker_values = lambda x: x * d else: if self.align == OWNomogram.ALIGN_LEFT: self.scale_marker_values = lambda x: x - minimums else: self.scale_marker_values = lambda x: x point_item, nomogram_head = self.create_main_nomogram( attributes, attr_inds, name_items, points, max_width, point_text, name_offset) probs_item, nomogram_foot = self.create_footer_nomogram( probs_text, d, minimums, max_width, name_offset) for item in self.feature_items.values(): item.dot.point_dot = point_item.dot item.dot.probs_dot = probs_item.dot item.dot.vertical_line = self.hidden_vertical_line self.nomogram = nomogram = NomogramItem() nomogram.add_items([nomogram_head, self.nomogram_main, nomogram_foot]) self.scene.addItem(nomogram) self.set_feature_marker_values() rect = QRectF(self.scene.itemsBoundingRect().x(), self.scene.itemsBoundingRect().y(), self.scene.itemsBoundingRect().width(), self.nomogram.preferredSize().height()).adjusted(10, 0, 20, 0) self.scene.setSceneRect(rect) # Clip top and bottom (60 and 150) parts from the main view self.view.setSceneRect(rect.x(), rect.y() + 80, rect.width() - 10, rect.height() - 160) self.view.viewport().setMaximumHeight(rect.height() - 160) # Clip main part from top/bottom views # below point values are imprecise (less/more than required) but this # is not a problem due to clipped scene content still being drawn self.top_view.setSceneRect(rect.x(), rect.y() + 3, rect.width() - 10, 20) self.bottom_view.setSceneRect(rect.x(), rect.height() - 110, rect.width() - 10, 30) def create_main_nomogram(self, attributes, attr_inds, name_items, points, max_width, point_text, name_offset): cls_index = self.target_class_index min_p = min(p.min() for p in points) max_p = max(p.max() for p in points) values = self.get_ruler_values(min_p, max_p, max_width) min_p, max_p = min(values), max(values) diff_ = np.nan_to_num(max_p - min_p) scale_x = max_width / diff_ if diff_ else max_width nomogram_header = NomogramItem() point_item = RulerItem(point_text, values, scale_x, name_offset, - scale_x * min_p) point_item.setPreferredSize(point_item.preferredWidth(), 35) nomogram_header.add_items([point_item]) self.nomogram_main = NomogramItem() cont_feature_item_class = ContinuousFeature2DItem if \ self.cont_feature_dim_index else ContinuousFeatureItem feature_items = [ DiscreteFeatureItem( name_item, attr.values, point, scale_x, name_offset, - scale_x * min_p) if attr.is_discrete else cont_feature_item_class( name_item, self.log_reg_cont_data_extremes[i][cls_index], self.get_ruler_values( point.min(), point.max(), scale_x * point.ptp(), False), scale_x, name_offset, - scale_x * min_p) for i, attr, name_item, point in zip(attr_inds, attributes, name_items, points)] self.nomogram_main.add_items(feature_items) self.feature_items = OrderedDict(sorted(zip(attr_inds, feature_items))) x = - scale_x * min_p y = self.nomogram_main.layout().preferredHeight() + 10 self.vertical_line = QGraphicsLineItem(x, -6, x, y) self.vertical_line.setPen(QPen(Qt.DotLine)) self.vertical_line.setParentItem(point_item) self.hidden_vertical_line = QGraphicsLineItem(x, -6, x, y) pen = QPen(Qt.DashLine) pen.setBrush(QColor(Qt.red)) self.hidden_vertical_line.setPen(pen) self.hidden_vertical_line.setParentItem(point_item) return point_item, nomogram_header def get_ordered_attributes(self): """Return (in_domain_index, attr) pairs, ordered by method in SortBy combo""" if self.domain is None or not self.domain.attributes: return [] attrs = self.domain.attributes sort_by = self.sort_index class_value = self.target_class_index if sort_by == SortBy.NO_SORTING: return list(enumerate(attrs)) elif sort_by == SortBy.NAME: def key(x): _, attr = x return attr.name.lower() elif sort_by == SortBy.ABSOLUTE: def key(x): i, attr = x if attr.is_discrete: ptp = self.points[i][class_value].ptp() else: coef = np.abs(self.log_reg_coeffs_orig[i][class_value]).mean() ptp = coef * np.ptp(self.log_reg_cont_data_extremes[i][class_value]) return -ptp elif sort_by == SortBy.POSITIVE: def key(x): i, attr = x max_value = (self.points[i][class_value].max() if attr.is_discrete else np.mean(self.log_reg_cont_data_extremes[i][class_value])) return -max_value elif sort_by == SortBy.NEGATIVE: def key(x): i, attr = x min_value = (self.points[i][class_value].min() if attr.is_discrete else np.mean(self.log_reg_cont_data_extremes[i][class_value])) return min_value return sorted(enumerate(attrs), key=key) def create_footer_nomogram(self, probs_text, d, minimums, max_width, name_offset): eps, d_ = 0.05, 1 k = - np.log(self.p / (1 - self.p)) if self.p is not None else - self.b0 min_sum = k[self.target_class_index] - np.log((1 - eps) / eps) max_sum = k[self.target_class_index] - np.log(eps / (1 - eps)) if self.align == OWNomogram.ALIGN_LEFT: max_sum = max_sum - sum(minimums) min_sum = min_sum - sum(minimums) for i in range(len(k)): k[i] = k[i] - sum([min(q) for q in [p[i] for p in self.points]]) if self.scale == OWNomogram.POINT_SCALE: min_sum *= d max_sum *= d d_ = d values = self.get_ruler_values(min_sum, max_sum, max_width) min_sum, max_sum = min(values), max(values) diff_ = np.nan_to_num(max_sum - min_sum) scale_x = max_width / diff_ if diff_ else max_width cls_var, cls_index = self.domain.class_var, self.target_class_index nomogram_footer = NomogramItem() def get_normalized_probabilities(val): if not self.normalize_probabilities: return 1 / (1 + np.exp(k[cls_index] - val / d_)) totals = self.__get_totals_for_class_values(minimums) p_sum = np.sum(1 / (1 + np.exp(k - totals / d_))) return 1 / (1 + np.exp(k[cls_index] - val / d_)) / p_sum def get_points(prob): if not self.normalize_probabilities: return (k[cls_index] - np.log(1 / prob - 1)) * d_ totals = self.__get_totals_for_class_values(minimums) p_sum = np.sum(1 / (1 + np.exp(k - totals / d_))) return (k[cls_index] - np.log(1 / (prob * p_sum) - 1)) * d_ probs_item = ProbabilitiesRulerItem( probs_text, values, scale_x, name_offset, - scale_x * min_sum, get_points=get_points, title="{}='{}'".format(cls_var.name, cls_var.values[cls_index]), get_probabilities=get_normalized_probabilities) nomogram_footer.add_items([probs_item]) return probs_item, nomogram_footer def __get_totals_for_class_values(self, minimums): cls_index = self.target_class_index marker_values = self.scale_marker_values(self.feature_marker_values) totals = np.full(len(self.domain.class_var.values), np.nan) totals[cls_index] = marker_values.sum() for i in range(len(self.domain.class_var.values)): if i == cls_index: continue coeffs = [np.nan_to_num(p[i] / p[cls_index]) for p in self.points] points = [p[cls_index] for p in self.points] total = sum([self.get_points_from_coeffs(v, c, p) for (v, c, p) in zip(self.feature_marker_values, coeffs, points)]) if self.align == OWNomogram.ALIGN_LEFT: points = [p - m for m, p in zip(minimums, points)] total -= sum([min(p) for p in [p[i] for p in self.points]]) d = 100 / max(max(abs(p)) for p in points) if self.scale == OWNomogram.POINT_SCALE: total *= d totals[i] = total assert not np.any(np.isnan(totals)) return totals def set_feature_marker_values(self): if not (len(self.points) and len(self.feature_items)): return if not len(self.feature_marker_values): self._init_feature_marker_values() marker_values = self.scale_marker_values(self.feature_marker_values) invisible_sum = 0 for i in range(len(marker_values)): try: item = self.feature_items[i] except KeyError: invisible_sum += marker_values[i] else: item.dot.move_to_val(marker_values[i]) item.dot.probs_dot.move_to_sum(invisible_sum) def _init_feature_marker_values(self): self.feature_marker_values = [] cls_index = self.target_class_index instances = Table(self.domain, self.instances) \ if self.instances else None values = [] for i, attr in enumerate(self.domain.attributes): value, feature_val = 0, None if len(self.log_reg_coeffs): if attr.is_discrete: ind, n = unique(self.data.X[:, i], return_counts=True) feature_val = np.nan_to_num(ind[np.argmax(n)]) else: feature_val = nanmean(self.data.X[:, i]) # If data is provided on a separate signal, use the first data # instance to position the points instead of the mean inst_in_dom = instances and attr in instances.domain if inst_in_dom and not np.isnan(instances[0][attr]): feature_val = instances[0][attr] if feature_val is not None: value = (self.points[i][cls_index][int(feature_val)] if attr.is_discrete else self.log_reg_coeffs_orig[i][cls_index][0] * feature_val) values.append(value) self.feature_marker_values = np.asarray(values) def clear_scene(self): self.feature_items = {} self.scale_marker_values = lambda x: x self.nomogram = None self.nomogram_main = None self.vertical_line = None self.hidden_vertical_line = None self.scene.clear() def send_report(self): self.report_plot() @staticmethod def reconstruct_domain(original, preprocessed): # abuse dict to make "in" comparisons faster attrs = OrderedDict() for attr in preprocessed.attributes: cv = attr._compute_value.variable._compute_value var = cv.variable if cv else original[attr.name] if var in attrs: # the reason for OrderedDict continue attrs[var] = None # we only need keys attrs = list(attrs.keys()) return Domain(attrs, original.class_var, original.metas) @staticmethod def get_ruler_values(start, stop, max_width, round_to_nearest=True): if max_width == 0: return [0] diff = np.nan_to_num((stop - start) / max_width) if diff <= 0: return [0] decimals = int(np.floor(np.log10(diff))) if diff > 4 * pow(10, decimals): step = 5 * pow(10, decimals + 2) elif diff > 2 * pow(10, decimals): step = 2 * pow(10, decimals + 2) elif diff > 1 * pow(10, decimals): step = 1 * pow(10, decimals + 2) else: step = 5 * pow(10, decimals + 1) round_by = int(- np.floor(np.log10(step))) r = start % step if not round_to_nearest: _range = np.arange(start + step, stop + r, step) - r start, stop = np.floor(start * 100) / 100, np.ceil(stop * 100) / 100 return np.round(np.hstack((start, _range, stop)), 2) return np.round(np.arange(start, stop + r + step, step) - r, round_by) @staticmethod def get_points_from_coeffs(current_value, coefficients, possible_values): if np.isnan(possible_values).any(): return 0 indices = np.argsort(possible_values) sorted_values = possible_values[indices] sorted_coefficients = coefficients[indices] for i, val in enumerate(sorted_values): if current_value < val: break diff = sorted_values[i] - sorted_values[i - 1] k = 0 if diff < 1e-6 else (sorted_values[i] - current_value) / \ (sorted_values[i] - sorted_values[i - 1]) return sorted_coefficients[i - 1] * sorted_values[i - 1] * k + \ sorted_coefficients[i] * sorted_values[i] * (1 - k)
def __init__(self, name, data_extremes, values, scale, name_offset, offset): super().__init__() data_start, data_stop = data_extremes[0], data_extremes[1] labels = [str(np.round(data_start + (data_stop - data_start) * i / (self.n_tck - 1), 1)) for i in range(self.n_tck)] # leading label font = name.document().defaultFont() name.setFont(font) name.setPos(name_offset, -10) name.setParentItem(self) # labels ascending = data_start < data_stop y_start, y_stop = (self.y_diff, 0) if ascending else (0, self.y_diff) for i in range(self.n_tck): text = QGraphicsSimpleTextItem(labels[i], self) w = text.boundingRect().width() y = y_start + (y_stop - y_start) / (self.n_tck - 1) * i text.setPos(-5 - w, y - 8) tick = QGraphicsLineItem(-2, y, 2, y, self) # prediction marker self.dot = Continuous2DMovableDotItem( self.DOT_RADIUS, scale, offset, values[0], values[-1], y_start, y_stop) self.dot.tooltip_labels = labels self.dot.tooltip_values = values self.dot.setParentItem(self) h_line = QGraphicsLineItem(values[0] * scale + offset, self.y_diff / 2, values[-1] * scale + offset, self.y_diff / 2, self) pen = QPen(Qt.DashLine) pen.setBrush(QColor(Qt.red)) h_line.setPen(pen) self.dot.horizontal_line = h_line # pylint: disable=unused-variable # line line = QGraphicsLineItem(values[0] * scale + offset, y_start, values[-1] * scale + offset, y_stop, self) # ticks for value in values: diff_ = np.nan_to_num(values[-1] - values[0]) k = (value - values[0]) / diff_ if diff_ else 0 y_tick = (y_stop - y_start) * k + y_start - self.tick_height / 2 x_tick = value * scale - self.tick_width / 2 + offset tick = QGraphicsRectItem( x_tick, y_tick, self.tick_width, self.tick_height, self) tick.setBrush(QColor(Qt.black)) # rect rect = QGraphicsRectItem( values[0] * scale + offset, -self.y_diff * 0.125, values[-1] * scale + offset, self.y_diff * 1.25, self) pen = QPen(Qt.DotLine) pen.setBrush(QColor(50, 150, 200, 255)) rect.setPen(pen) self.setPreferredSize(self.preferredWidth(), self.y_diff * 1.5)
def _offseted_line(ax, ay): r = QGraphicsLineItem(x + ax, y + ay, x + (ax or w), y + (ay or h)) self.canvas.addItem(r) r.setPen(pen)
class FeaturesPlot(QGraphicsWidget): BOTTOM_AXIS_LABEL = "Feature Importance" LABEL_COLUMN, ITEM_COLUMN = range(2) ITEM_COLUMN_WIDTH, OFFSET = 300, 80 selection_cleared = Signal() selection_changed = Signal(object) resized = Signal() def __init__(self): super().__init__() self._item_column_width = self.ITEM_COLUMN_WIDTH self._range: Optional[Tuple[float, float]] = None self._items: List[FeatureItem] = [] self._variable_items: List[VariableItem] = [] self._bottom_axis = AxisItem(parent=self, orientation="bottom", maxTickLength=7, pen=QPen(Qt.black)) self._bottom_axis.setLabel(self.BOTTOM_AXIS_LABEL) self._vertical_line = QGraphicsLineItem(self._bottom_axis) self._vertical_line.setPen(QPen(Qt.gray)) self._layout = QGraphicsGridLayout() self._layout.setVerticalSpacing(0) self.setLayout(self._layout) self.parameter_setter = BaseParameterSetter(self) @property def item_column_width(self) -> int: return self._item_column_width @item_column_width.setter def item_column_width(self, view_width: int): j = FeaturesPlot.LABEL_COLUMN w = max([ self._layout.itemAt(i, j).item.boundingRect().width() for i in range(len(self._items)) ] + [0]) width = view_width - self.OFFSET - w self._item_column_width = max(self.ITEM_COLUMN_WIDTH, width) @property def x0_scaled(self) -> float: min_max = self._range[1] - self._range[0] return -self._range[0] * self.item_column_width / min_max @property def bottom_axis(self) -> AxisItem: return self._bottom_axis @property def labels(self) -> List[VariableItem]: return self._variable_items def set_data(self, x: np.ndarray, names: List[str], n_attrs: int, view_width: int, *plot_args): self.item_column_width = view_width self._set_range(x, *plot_args) self._set_items(x, names, *plot_args) self._set_labels(names) self._set_bottom_axis() self.set_n_visible(n_attrs) def _set_range(self, *_): raise NotImplementedError def _set_items(self, *_): raise NotImplementedError def set_n_visible(self, n: int): for i in range(len(self._items)): item = self._layout.itemAt(i, FeaturesPlot.ITEM_COLUMN) item.setVisible(i < n) text_item = self._layout.itemAt(i, FeaturesPlot.LABEL_COLUMN).item text_item.setVisible(i < n) self.set_vertical_line() def rescale(self, view_width: int): self.item_column_width = view_width for item in self._items: item.rescale(self.item_column_width) self._bottom_axis.setWidth(self.item_column_width) x = self.x0_scaled self._vertical_line.setLine(x, 0, x, self._vertical_line.line().y2()) self.updateGeometry() def set_height(self, height: float): for i in range(len(self._items)): item = self._layout.itemAt(i, FeaturesPlot.ITEM_COLUMN) item.set_height(height) self.set_vertical_line() self.updateGeometry() def _set_labels(self, labels: List[str]): for i, (label, _) in enumerate(zip(labels, self._items)): text = VariableItem(self, label) item = SimpleLayoutItem(text) item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self._layout.addItem(item, i, FeaturesPlot.LABEL_COLUMN, Qt.AlignRight | Qt.AlignVCenter) self._variable_items.append(item) def _set_bottom_axis(self): self._bottom_axis.setRange(*self._range) self._layout.addItem(self._bottom_axis, len(self._items), FeaturesPlot.ITEM_COLUMN) def set_vertical_line(self): height = 0 for i in range(len(self._items)): item = self._layout.itemAt(i, FeaturesPlot.ITEM_COLUMN) text_item = self._layout.itemAt(i, FeaturesPlot.LABEL_COLUMN).item if item.isVisible(): height += max(text_item.boundingRect().height(), item.preferredSize().height()) self._vertical_line.setLine(self.x0_scaled, 0, self.x0_scaled, -height) def deselect(self): self.selection_cleared.emit() def select(self, *args): self.selection_changed.emit(*args) def select_from_settings(self, *_): raise NotImplementedError def apply_visual_settings(self, settings: Dict): for key, value in settings.items(): self.parameter_setter.set_parameter(key, value)
def line(x1, y1, x2, y2): r = QGraphicsLineItem(x1, y1, x2, y2, None) self.canvas.addItem(r) r.setPen(QPen(Qt.white, 2)) r.setZValue(30)
class OWAxis(QGraphicsItem): Role = OWPalette.Axis def __init__(self, id, title='', title_above=False, title_location=AxisMiddle, line=None, arrows=0, plot=None, bounds=None): QGraphicsItem.__init__(self) self.setFlag(QGraphicsItem.ItemHasNoContents) self.setZValue(AxisZValue) self.id = id self.title = title self.title_location = title_location self.data_line = line self.plot = plot self.graph_line = None self.size = None self.scale = None self.tick_length = (10, 5, 0) self.arrows = arrows self.title_above = title_above self.line_item = QGraphicsLineItem(self) self.title_item = QGraphicsTextItem(self) self.end_arrow_item = None self.start_arrow_item = None self.show_title = False self.scale = None path = QPainterPath() path.setFillRule(Qt.WindingFill) path.moveTo(0, 3.09) path.lineTo(0, -3.09) path.lineTo(9.51, 0) path.closeSubpath() self.arrow_path = path self.label_items = [] self.label_bg_items = [] self.tick_items = [] self._ticks = [] self.zoom_transform = QTransform() self.labels = None self.values = None self._bounds = bounds self.auto_range = None self.auto_scale = True self.zoomable = False self.update_callback = None self.max_text_width = 50 self.text_margin = 5 self.always_horizontal_text = False @staticmethod def compute_scale(min, max): magnitude = int(3 * log10(abs(max - min)) + 1) if magnitude % 3 == 0: first_place = 1 elif magnitude % 3 == 1: first_place = 2 else: first_place = 5 magnitude = magnitude // 3 - 1 step = first_place * pow(10, magnitude) first_val = ceil(min / step) * step return first_val, step def update_ticks(self): self._ticks = [] major, medium, minor = self.tick_length if self.labels is not None and not self.auto_scale: values = self.values or range(len(self.labels)) for i, text in zip(values, self.labels): self._ticks.append((i, text, medium, 1)) else: if self.scale and not self.auto_scale: min, max, step = self.scale elif self.auto_range: min, max = self.auto_range if min is not None and max is not None: step = (max - min) / 10 else: return else: return if max == min: return val, step = self.compute_scale(min, max) while val <= max: self._ticks.append((val, "%.4g" % val, medium, step)) val += step def update_graph(self): if self.update_callback: self.update_callback() def update(self, zoom_only=False): self.update_ticks() line_color = self.plot.color(OWPalette.Axis) text_color = self.plot.color(OWPalette.Text) if not self.graph_line or not self.scene(): return self.line_item.setLine(self.graph_line) self.line_item.setPen(line_color) if self.title: self.title_item.setHtml('<b>' + self.title + '</b>') self.title_item.setDefaultTextColor(text_color) if self.title_location == AxisMiddle: title_p = 0.5 elif self.title_location == AxisEnd: title_p = 0.95 else: title_p = 0.05 title_pos = self.graph_line.pointAt(title_p) v = self.graph_line.normalVector().unitVector() dense_text = False if hasattr(self, 'title_margin'): offset = self.title_margin elif self._ticks: if self.should_be_expanded(): offset = 55 dense_text = True else: offset = 35 else: offset = 10 if self.title_above: title_pos += (v.p2() - v.p1()) * (offset + QFontMetrics(self.title_item.font()).height()) else: title_pos -= (v.p2() - v.p1()) * offset ## TODO: Move it according to self.label_pos self.title_item.setVisible(self.show_title) self.title_item.setRotation(-self.graph_line.angle()) c = self.title_item.mapToParent(self.title_item.boundingRect().center()) tl = self.title_item.mapToParent(self.title_item.boundingRect().topLeft()) self.title_item.setPos(title_pos - c + tl) ## Arrows if not zoom_only: if self.start_arrow_item: self.scene().removeItem(self.start_arrow_item) self.start_arrow_item = None if self.end_arrow_item: self.scene().removeItem(self.end_arrow_item) self.end_arrow_item = None if self.arrows & AxisStart: if not zoom_only or not self.start_arrow_item: self.start_arrow_item = QGraphicsPathItem(self.arrow_path, self) self.start_arrow_item.setPos(self.graph_line.p1()) self.start_arrow_item.setRotation(-self.graph_line.angle() + 180) self.start_arrow_item.setBrush(line_color) self.start_arrow_item.setPen(line_color) if self.arrows & AxisEnd: if not zoom_only or not self.end_arrow_item: self.end_arrow_item = QGraphicsPathItem(self.arrow_path, self) self.end_arrow_item.setPos(self.graph_line.p2()) self.end_arrow_item.setRotation(-self.graph_line.angle()) self.end_arrow_item.setBrush(line_color) self.end_arrow_item.setPen(line_color) ## Labels n = len(self._ticks) resize_plot_item_list(self.label_items, n, QGraphicsTextItem, self) resize_plot_item_list(self.label_bg_items, n, QGraphicsRectItem, self) resize_plot_item_list(self.tick_items, n, QGraphicsLineItem, self) test_rect = QRectF(self.graph_line.p1(), self.graph_line.p2()).normalized() test_rect.adjust(-1, -1, 1, 1) n_v = self.graph_line.normalVector().unitVector() if self.title_above: n_p = n_v.p2() - n_v.p1() else: n_p = n_v.p1() - n_v.p2() l_v = self.graph_line.unitVector() l_p = l_v.p2() - l_v.p1() for i in range(n): pos, text, size, step = self._ticks[i] hs = 0.5 * step tick_pos = self.map_to_graph(pos) if not test_rect.contains(tick_pos): self.tick_items[i].setVisible(False) self.label_items[i].setVisible(False) continue item = self.label_items[i] item.setVisible(True) if not zoom_only: if self.id in XAxes or getattr(self, 'is_horizontal', False): item.setHtml('<center>' + Qt.escape(text.strip()) + '</center>') else: item.setHtml(Qt.escape(text.strip())) item.setTextWidth(-1) text_angle = 0 if dense_text: w = min(item.boundingRect().width(), self.max_text_width) item.setTextWidth(w) if self.title_above: label_pos = tick_pos + n_p * (w + self.text_margin) + l_p * item.boundingRect().height() / 2 else: label_pos = tick_pos + n_p * self.text_margin + l_p * item.boundingRect().height() / 2 text_angle = -90 if self.title_above else 90 else: w = min(item.boundingRect().width(), QLineF(self.map_to_graph(pos - hs), self.map_to_graph(pos + hs)).length()) label_pos = tick_pos + n_p * self.text_margin + l_p * item.boundingRect().height() / 2 item.setTextWidth(w) if not self.always_horizontal_text: if self.title_above: item.setRotation(-self.graph_line.angle() - text_angle) else: item.setRotation(self.graph_line.angle() - text_angle) item.setPos(label_pos) item.setDefaultTextColor(text_color) self.label_bg_items[i].setRect(item.boundingRect()) self.label_bg_items[i].setPen(QPen(Qt.NoPen)) self.label_bg_items[i].setBrush(self.plot.color(OWPalette.Canvas)) item = self.tick_items[i] item.setVisible(True) tick_line = QLineF(v) tick_line.translate(-tick_line.p1()) tick_line.setLength(size) if self.title_above: tick_line.setAngle(tick_line.angle() + 180) item.setLine(tick_line) item.setPen(line_color) item.setPos(self.map_to_graph(pos)) @staticmethod def make_title(label, unit=None): lab = '<i>' + label + '</i>' if unit: lab = lab + ' [' + unit + ']' return lab def set_line(self, line): self.graph_line = line self.update() def set_title(self, title): self.title = title self.update() def set_show_title(self, b): self.show_title = b self.update() def set_labels(self, labels, values): self.labels = labels self.values = values self.graph_line = None self.auto_scale = False self.update_ticks() self.update_graph() def set_scale(self, min, max, step_size): self.scale = (min, max, step_size) self.graph_line = None self.auto_scale = False self.update_ticks() self.update_graph() def set_tick_length(self, minor, medium, major): self.tick_length = (minor, medium, major) self.update() def map_to_graph(self, x): min, max = self.plot.bounds_for_axis(self.id) if min == max: return QPointF() line_point = self.graph_line.pointAt((x - min) / (max - min)) end_point = line_point * self.zoom_transform return self.projection(end_point, self.graph_line) @staticmethod def projection(point, line): norm = line.normalVector() norm.translate(point - norm.p1()) p = QPointF() type = line.intersect(norm, p) return p def continuous_labels(self): min, max, step = self.scale magnitude = log10(abs(max - min)) def paint(self, painter, option, widget): pass def boundingRect(self): return QRectF() def ticks(self): if not self._ticks: self.update_ticks() return self._ticks def bounds(self): if self._bounds: return self._bounds if self.labels: return -0.2, len(self.labels) - 0.8 elif self.scale: min, max, _step = self.scale return min, max elif self.auto_range: return self.auto_range else: return 0, 1 def set_bounds(self, value): self._bounds = value def should_be_expanded(self): self.update_ticks() return self.id in YAxes or self.always_horizontal_text or sum( len(t[1]) for t in self._ticks) * 12 > self.plot.width()
class ViolinPlot(QGraphicsWidget): LABEL_COLUMN, VIOLIN_COLUMN, LEGEND_COLUMN = range(3) VIOLIN_COLUMN_WIDTH, OFFSET = 300, 250 MAX_N_ITEMS = 100 MAX_ATTR_LEN = 20 selection_cleared = Signal() selection_changed = Signal(float, float, str) def __init__(self): super().__init__() self.__violin_column_width = self.VIOLIN_COLUMN_WIDTH # type: int self.__range = None # type: Optional[Tuple[float, float]] self.__violin_items = [] # type: List[ViolinItem] self.__bottom_axis = pg.AxisItem(parent=self, orientation="bottom", maxTickLength=7, pen=QPen(Qt.black)) self.__bottom_axis.setLabel("Impact on model output") self.__vertical_line = QGraphicsLineItem(self.__bottom_axis) self.__vertical_line.setPen(QPen(Qt.gray)) self.__legend = Legend(self) self.__layout = QGraphicsGridLayout() self.__layout.addItem(self.__legend, 0, ViolinPlot.LEGEND_COLUMN) self.__layout.setVerticalSpacing(0) self.setLayout(self.__layout) @property def violin_column_width(self): return self.__violin_column_width @violin_column_width.setter def violin_column_width(self, view_width: int): self.__violin_column_width = max(self.VIOLIN_COLUMN_WIDTH, view_width - self.OFFSET) @property def bottom_axis(self): return self.__bottom_axis def set_data(self, x: np.ndarray, colors: np.ndarray, names: List[str], n_attrs: float, view_width: int): self.violin_column_width = view_width abs_max = np.max(np.abs(x)) * 1.05 self.__range = (-abs_max, abs_max) self._set_violin_items(x, colors, names) self._set_labels(names) self._set_bottom_axis() self._set_vertical_line() self.set_n_visible(n_attrs) def set_n_visible(self, n: int): for i in range(len(self.__violin_items)): violin_item = self.__layout.itemAt(i, ViolinPlot.VIOLIN_COLUMN) violin_item.setVisible(i < n) text_item = self.__layout.itemAt(i, ViolinPlot.LABEL_COLUMN).item text_item.setVisible(i < n) x = self.__vertical_line.line().x1() n = min(n, len(self.__violin_items)) self.__vertical_line.setLine(x, 0, x, -ViolinItem.HEIGHT * n) def rescale(self, view_width: int): self.violin_column_width = view_width with temp_seed(0): for item in self.__violin_items: item.rescale(self.violin_column_width) self.__bottom_axis.setWidth(self.violin_column_width) x = self.violin_column_width / 2 self.__vertical_line.setLine(x, 0, x, self.__vertical_line.line().y2()) def show_legend(self, show: bool): self.__legend.setVisible(show) self.__bottom_axis.setWidth(self.violin_column_width) x = self.violin_column_width / 2 self.__vertical_line.setLine(x, 0, x, self.__vertical_line.line().y2()) def _set_violin_items(self, x: np.ndarray, colors: np.ndarray, labels: List[str]): with temp_seed(0): for i in range(x.shape[1]): item = ViolinItem(self, labels[i], self.__range, self.violin_column_width) item.set_data(x[:, i], colors[:, i]) item.selection_changed.connect(self.select) self.__violin_items.append(item) self.__layout.addItem(item, i, ViolinPlot.VIOLIN_COLUMN) if i == self.MAX_N_ITEMS: break def _set_labels(self, labels: List[str]): for i, (label, _) in enumerate(zip(labels, self.__violin_items)): short = f"{label[:self.MAX_ATTR_LEN - 1]}..." \ if len(label) > self.MAX_ATTR_LEN else label text = QGraphicsSimpleTextItem(short, self) text.setToolTip(label) item = SimpleLayoutItem(text) item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.__layout.addItem(item, i, ViolinPlot.LABEL_COLUMN, Qt.AlignRight | Qt.AlignVCenter) def _set_bottom_axis(self): self.__bottom_axis.setRange(*self.__range) self.__layout.addItem(self.__bottom_axis, len(self.__violin_items), ViolinPlot.VIOLIN_COLUMN) def _set_vertical_line(self): x = self.violin_column_width / 2 n = len(self.__violin_items) self.__vertical_line.setLine(x, 0, x, -ViolinItem.HEIGHT * n) def deselect(self): self.selection_cleared.emit() def select(self, *args): self.selection_changed.emit(*args) def select_from_settings(self, x1: float, x2: float, attr_name: str): point_r_diff = 2 * self.__range[1] / (self.violin_column_width / 2) for item in self.__violin_items: if item.attr_name == attr_name: item.add_selection_rect(x1 - point_r_diff, x2 + point_r_diff) break self.select(x1, x2, attr_name)
class ViolinPlot(QGraphicsWidget): LABEL_COLUMN, VIOLIN_COLUMN, LEGEND_COLUMN = range(3) VIOLIN_COLUMN_WIDTH, OFFSET = 300, 80 MAX_N_ITEMS = 100 selection_cleared = Signal() selection_changed = Signal(float, float, str) resized = Signal() def __init__(self): super().__init__() self.__violin_column_width = self.VIOLIN_COLUMN_WIDTH # type: int self.__range = None # type: Optional[Tuple[float, float]] self.__violin_items = [] # type: List[ViolinItem] self.__variable_items = [] # type: List[VariableItem] self.__bottom_axis = AxisItem(parent=self, orientation="bottom", maxTickLength=7, pen=QPen(Qt.black)) self.__bottom_axis.setLabel("Impact on model output") self.__vertical_line = QGraphicsLineItem(self.__bottom_axis) self.__vertical_line.setPen(QPen(Qt.gray)) self.__legend = Legend(self) self.__layout = QGraphicsGridLayout() self.__layout.addItem(self.__legend, 0, ViolinPlot.LEGEND_COLUMN) self.__layout.setVerticalSpacing(0) self.setLayout(self.__layout) self.parameter_setter = ParameterSetter(self) @property def violin_column_width(self): return self.__violin_column_width @violin_column_width.setter def violin_column_width(self, view_width: int): j = ViolinPlot.LABEL_COLUMN w = max([self.__layout.itemAt(i, j).item.boundingRect().width() for i in range(len(self.__violin_items))] + [0]) width = view_width - self.legend.sizeHint().width() - self.OFFSET - w self.__violin_column_width = max(self.VIOLIN_COLUMN_WIDTH, width) @property def bottom_axis(self): return self.__bottom_axis @property def labels(self): return self.__variable_items @property def legend(self): return self.__legend def set_data(self, x: np.ndarray, colors: np.ndarray, names: List[str], n_attrs: float, view_width: int): self.violin_column_width = view_width abs_max = np.max(np.abs(x)) * 1.05 self.__range = (-abs_max, abs_max) self._set_violin_items(x, colors, names) self._set_labels(names) self._set_bottom_axis() self.set_n_visible(n_attrs) def set_n_visible(self, n: int): for i in range(len(self.__violin_items)): violin_item = self.__layout.itemAt(i, ViolinPlot.VIOLIN_COLUMN) violin_item.setVisible(i < n) text_item = self.__layout.itemAt(i, ViolinPlot.LABEL_COLUMN).item text_item.setVisible(i < n) self.set_vertical_line() def rescale(self, view_width: int): self.violin_column_width = view_width with temp_seed(0): for item in self.__violin_items: item.rescale(self.violin_column_width) self.__bottom_axis.setWidth(self.violin_column_width) x = self.violin_column_width / 2 self.__vertical_line.setLine(x, 0, x, self.__vertical_line.line().y2()) def show_legend(self, show: bool): self.__legend.setVisible(show) self.__bottom_axis.setWidth(self.violin_column_width) x = self.violin_column_width / 2 self.__vertical_line.setLine(x, 0, x, self.__vertical_line.line().y2()) def _set_violin_items(self, x: np.ndarray, colors: np.ndarray, labels: List[str]): with temp_seed(0): for i in range(x.shape[1]): item = ViolinItem(self, labels[i], self.__range, self.violin_column_width) item.set_data(x[:, i], colors[:, i]) item.selection_changed.connect(self.select) self.__violin_items.append(item) self.__layout.addItem(item, i, ViolinPlot.VIOLIN_COLUMN) if i == self.MAX_N_ITEMS: break def _set_labels(self, labels: List[str]): for i, (label, _) in enumerate(zip(labels, self.__violin_items)): text = VariableItem(self, label) item = SimpleLayoutItem(text) item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.__layout.addItem(item, i, ViolinPlot.LABEL_COLUMN, Qt.AlignRight | Qt.AlignVCenter) self.__variable_items.append(item) def _set_bottom_axis(self): self.__bottom_axis.setRange(*self.__range) self.__layout.addItem(self.__bottom_axis, len(self.__violin_items), ViolinPlot.VIOLIN_COLUMN) def set_vertical_line(self): x = self.violin_column_width / 2 height = 0 for i in range(len(self.__violin_items)): violin_item = self.__layout.itemAt(i, ViolinPlot.VIOLIN_COLUMN) text_item = self.__layout.itemAt(i, ViolinPlot.LABEL_COLUMN).item if violin_item.isVisible(): height += max(text_item.boundingRect().height(), violin_item.preferredSize().height()) self.__vertical_line.setLine(x, 0, x, -height) def deselect(self): self.selection_cleared.emit() def select(self, *args): self.selection_changed.emit(*args) def select_from_settings(self, x1: float, x2: float, attr_name: str): point_r_diff = 2 * self.__range[1] / (self.violin_column_width / 2) for item in self.__violin_items: if item.attr_name == attr_name: item.add_selection_rect(x1 - point_r_diff, x2 + point_r_diff) break self.select(x1, x2, attr_name) def apply_visual_settings(self, settings: Dict): for key, value in settings.items(): self.parameter_setter.set_parameter(key, value)
class AnchorItem(pg.GraphicsObject): def __init__(self, parent=None, line=QLineF(), text="", **kwargs): super().__init__(parent, **kwargs) self._text = text self.setFlag(pg.GraphicsObject.ItemHasNoContents) self._spine = QGraphicsLineItem(line, self) angle = line.angle() self._arrow = pg.ArrowItem(parent=self, angle=0) self._arrow.setPos(self._spine.line().p2()) self._arrow.setRotation(angle) self._arrow.setStyle(headLen=10) self._label = TextItem(text=text, color=(10, 10, 10)) self._label.setParentItem(self) self._label.setPos(*self.get_xy()) if parent is not None: self.setParentItem(parent) def get_xy(self): point = self._spine.line().p2() return point.x(), point.y() def setText(self, text): if text != self._text: self._text = text self._label.setText(text) self._label.setVisible(bool(text)) def text(self): return self._text def setLine(self, *line): line = QLineF(*line) if line != self._spine.line(): self._spine.setLine(line) self.__updateLayout() def line(self): return self._spine.line() def setPen(self, pen): self._spine.setPen(pen) def setArrowVisible(self, visible): self._arrow.setVisible(visible) def paint(self, painter, option, widget): pass def boundingRect(self): return QRectF() def viewTransformChanged(self): self.__updateLayout() def __updateLayout(self): T = self.sceneTransform() if T is None: T = QTransform() # map the axis spine to scene coord. system. viewbox_line = T.map(self._spine.line()) angle = viewbox_line.angle() assert not np.isnan(angle) # note in Qt the y axis is inverted (90 degree angle 'points' down) left_quad = 270 < angle <= 360 or -0.0 <= angle < 90 # position the text label along the viewbox_line label_pos = self._spine.line().pointAt(0.90) if left_quad: # Anchor the text under the axis spine anchor = (0.5, -0.1) else: # Anchor the text over the axis spine anchor = (0.5, 1.1) self._label.setPos(label_pos) self._label.setAnchor(pg.Point(*anchor)) self._label.setRotation(-angle if left_quad else 180 - angle) self._arrow.setPos(self._spine.line().p2()) self._arrow.setRotation(180 - angle)
class LinePlotViewBox(ViewBox): selection_changed = Signal(np.ndarray) def __init__(self): super().__init__(enableMenu=False) self._profile_items = None self._can_select = True self._graph_state = SELECT self.setMouseMode(self.PanMode) pen = mkPen(LinePlotStyle.SELECTION_LINE_COLOR, width=LinePlotStyle.SELECTION_LINE_WIDTH) self.selection_line = QGraphicsLineItem() self.selection_line.setPen(pen) self.selection_line.setZValue(1e9) self.addItem(self.selection_line, ignoreBounds=True) def update_selection_line(self, button_down_pos, current_pos): p1 = self.childGroup.mapFromParent(button_down_pos) p2 = self.childGroup.mapFromParent(current_pos) self.selection_line.setLine(QLineF(p1, p2)) self.selection_line.resetTransform() self.selection_line.show() def set_graph_state(self, state): self._graph_state = state def enable_selection(self, enable): self._can_select = enable def get_selected(self, p1, p2): if self._profile_items is None: return np.array(False) return line_intersects_profiles(np.array([p1.x(), p1.y()]), np.array([p2.x(), p2.y()]), self._profile_items) def add_profiles(self, y): if sp.issparse(y): y = y.todense() self._profile_items = np.array( [np.vstack((np.full((1, y.shape[0]), i + 1), y[:, i].flatten())).T for i in range(y.shape[1])]) def remove_profiles(self): self._profile_items = None def mouseDragEvent(self, event, axis=None): if self._graph_state == SELECT and axis is None and self._can_select: event.accept() if event.button() == Qt.LeftButton: self.update_selection_line(event.buttonDownPos(), event.pos()) if event.isFinish(): self.selection_line.hide() p1 = self.childGroup.mapFromParent( event.buttonDownPos(event.button())) p2 = self.childGroup.mapFromParent(event.pos()) self.selection_changed.emit(self.get_selected(p1, p2)) elif self._graph_state == ZOOMING or self._graph_state == PANNING: event.ignore() super().mouseDragEvent(event, axis=axis) else: event.ignore() def mouseClickEvent(self, event): if event.button() == Qt.RightButton: self.autoRange() self.enableAutoRange() else: event.accept() self.selection_changed.emit(np.array(False)) def reset(self): self._profile_items = None self._can_select = True self._graph_state = SELECT
class OWAxis(QGraphicsItem): Role = OWPalette.Axis def __init__(self, id, title='', title_above=False, title_location=AxisMiddle, line=None, arrows=0, plot=None, bounds=None): QGraphicsItem.__init__(self) self.setFlag(QGraphicsItem.ItemHasNoContents) self.setZValue(AxisZValue) self.id = id self.title = title self.title_location = title_location self.data_line = line self.plot = plot self.graph_line = None self.size = None self.scale = None self.tick_length = (10, 5, 0) self.arrows = arrows self.title_above = title_above self.line_item = QGraphicsLineItem(self) self.title_item = QGraphicsTextItem(self) self.end_arrow_item = None self.start_arrow_item = None self.show_title = False self.scale = None path = QPainterPath() path.setFillRule(Qt.WindingFill) path.moveTo(0, 3.09) path.lineTo(0, -3.09) path.lineTo(9.51, 0) path.closeSubpath() self.arrow_path = path self.label_items = [] self.label_bg_items = [] self.tick_items = [] self._ticks = [] self.zoom_transform = QTransform() self.labels = None self.values = None self._bounds = bounds self.auto_range = None self.auto_scale = True self.zoomable = False self.update_callback = None self.max_text_width = 50 self.text_margin = 5 self.always_horizontal_text = False @staticmethod def compute_scale(min, max): magnitude = int(3 * log10(abs(max - min)) + 1) if magnitude % 3 == 0: first_place = 1 elif magnitude % 3 == 1: first_place = 2 else: first_place = 5 magnitude = magnitude // 3 - 1 step = first_place * pow(10, magnitude) first_val = ceil(min / step) * step return first_val, step def update_ticks(self): self._ticks = [] major, medium, minor = self.tick_length if self.labels is not None and not self.auto_scale: values = self.values or range(len(self.labels)) for i, text in zip(values, self.labels): self._ticks.append((i, text, medium, 1)) else: if self.scale and not self.auto_scale: min, max, step = self.scale elif self.auto_range: min, max = self.auto_range if min is not None and max is not None: step = (max - min) / 10 else: return else: return if max == min: return val, step = self.compute_scale(min, max) while val <= max: self._ticks.append((val, "%.4g" % val, medium, step)) val += step def update_graph(self): if self.update_callback: self.update_callback() def update(self, zoom_only=False): self.update_ticks() line_color = self.plot.color(OWPalette.Axis) text_color = self.plot.color(OWPalette.Text) if not self.graph_line or not self.scene(): return self.line_item.setLine(self.graph_line) self.line_item.setPen(line_color) if self.title: self.title_item.setHtml('<b>' + self.title + '</b>') self.title_item.setDefaultTextColor(text_color) if self.title_location == AxisMiddle: title_p = 0.5 elif self.title_location == AxisEnd: title_p = 0.95 else: title_p = 0.05 title_pos = self.graph_line.pointAt(title_p) v = self.graph_line.normalVector().unitVector() dense_text = False if hasattr(self, 'title_margin'): offset = self.title_margin elif self._ticks: if self.should_be_expanded(): offset = 55 dense_text = True else: offset = 35 else: offset = 10 if self.title_above: title_pos += (v.p2() - v.p1()) * ( offset + QFontMetrics(self.title_item.font()).height()) else: title_pos -= (v.p2() - v.p1()) * offset ## TODO: Move it according to self.label_pos self.title_item.setVisible(self.show_title) self.title_item.setRotation(-self.graph_line.angle()) c = self.title_item.mapToParent( self.title_item.boundingRect().center()) tl = self.title_item.mapToParent( self.title_item.boundingRect().topLeft()) self.title_item.setPos(title_pos - c + tl) ## Arrows if not zoom_only: if self.start_arrow_item: self.scene().removeItem(self.start_arrow_item) self.start_arrow_item = None if self.end_arrow_item: self.scene().removeItem(self.end_arrow_item) self.end_arrow_item = None if self.arrows & AxisStart: if not zoom_only or not self.start_arrow_item: self.start_arrow_item = QGraphicsPathItem( self.arrow_path, self) self.start_arrow_item.setPos(self.graph_line.p1()) self.start_arrow_item.setRotation(-self.graph_line.angle() + 180) self.start_arrow_item.setBrush(line_color) self.start_arrow_item.setPen(line_color) if self.arrows & AxisEnd: if not zoom_only or not self.end_arrow_item: self.end_arrow_item = QGraphicsPathItem(self.arrow_path, self) self.end_arrow_item.setPos(self.graph_line.p2()) self.end_arrow_item.setRotation(-self.graph_line.angle()) self.end_arrow_item.setBrush(line_color) self.end_arrow_item.setPen(line_color) ## Labels n = len(self._ticks) resize_plot_item_list(self.label_items, n, QGraphicsTextItem, self) resize_plot_item_list(self.label_bg_items, n, QGraphicsRectItem, self) resize_plot_item_list(self.tick_items, n, QGraphicsLineItem, self) test_rect = QRectF(self.graph_line.p1(), self.graph_line.p2()).normalized() test_rect.adjust(-1, -1, 1, 1) n_v = self.graph_line.normalVector().unitVector() if self.title_above: n_p = n_v.p2() - n_v.p1() else: n_p = n_v.p1() - n_v.p2() l_v = self.graph_line.unitVector() l_p = l_v.p2() - l_v.p1() for i in range(n): pos, text, size, step = self._ticks[i] hs = 0.5 * step tick_pos = self.map_to_graph(pos) if not test_rect.contains(tick_pos): self.tick_items[i].setVisible(False) self.label_items[i].setVisible(False) continue item = self.label_items[i] item.setVisible(True) if not zoom_only: if self.id in XAxes or getattr(self, 'is_horizontal', False): item.setHtml('<center>' + Qt.escape(text.strip()) + '</center>') else: item.setHtml(Qt.escape(text.strip())) item.setTextWidth(-1) text_angle = 0 if dense_text: w = min(item.boundingRect().width(), self.max_text_width) item.setTextWidth(w) if self.title_above: label_pos = tick_pos + n_p * ( w + self.text_margin ) + l_p * item.boundingRect().height() / 2 else: label_pos = tick_pos + n_p * self.text_margin + l_p * item.boundingRect( ).height() / 2 text_angle = -90 if self.title_above else 90 else: w = min( item.boundingRect().width(), QLineF(self.map_to_graph(pos - hs), self.map_to_graph(pos + hs)).length()) label_pos = tick_pos + n_p * self.text_margin + l_p * item.boundingRect( ).height() / 2 item.setTextWidth(w) if not self.always_horizontal_text: if self.title_above: item.setRotation(-self.graph_line.angle() - text_angle) else: item.setRotation(self.graph_line.angle() - text_angle) item.setPos(label_pos) item.setDefaultTextColor(text_color) self.label_bg_items[i].setRect(item.boundingRect()) self.label_bg_items[i].setPen(QPen(Qt.NoPen)) self.label_bg_items[i].setBrush(self.plot.color(OWPalette.Canvas)) item = self.tick_items[i] item.setVisible(True) tick_line = QLineF(v) tick_line.translate(-tick_line.p1()) tick_line.setLength(size) if self.title_above: tick_line.setAngle(tick_line.angle() + 180) item.setLine(tick_line) item.setPen(line_color) item.setPos(self.map_to_graph(pos)) @staticmethod def make_title(label, unit=None): lab = '<i>' + label + '</i>' if unit: lab = lab + ' [' + unit + ']' return lab def set_line(self, line): self.graph_line = line self.update() def set_title(self, title): self.title = title self.update() def set_show_title(self, b): self.show_title = b self.update() def set_labels(self, labels, values): self.labels = labels self.values = values self.graph_line = None self.auto_scale = False self.update_ticks() self.update_graph() def set_scale(self, min, max, step_size): self.scale = (min, max, step_size) self.graph_line = None self.auto_scale = False self.update_ticks() self.update_graph() def set_tick_length(self, minor, medium, major): self.tick_length = (minor, medium, major) self.update() def map_to_graph(self, x): min, max = self.plot.bounds_for_axis(self.id) if min == max: return QPointF() line_point = self.graph_line.pointAt((x - min) / (max - min)) end_point = line_point * self.zoom_transform return self.projection(end_point, self.graph_line) @staticmethod def projection(point, line): norm = line.normalVector() norm.translate(point - norm.p1()) p = QPointF() type = line.intersect(norm, p) return p def continuous_labels(self): min, max, step = self.scale magnitude = log10(abs(max - min)) def paint(self, painter, option, widget): pass def boundingRect(self): return QRectF() def ticks(self): if not self._ticks: self.update_ticks() return self._ticks def bounds(self): if self._bounds: return self._bounds if self.labels: return -0.2, len(self.labels) - 0.8 elif self.scale: min, max, _step = self.scale return min, max elif self.auto_range: return self.auto_range else: return 0, 1 def set_bounds(self, value): self._bounds = value def should_be_expanded(self): self.update_ticks() return self.id in YAxes or self.always_horizontal_text or sum( len(t[1]) for t in self._ticks) * 12 > self.plot.width()
class AnchorItem(pg.GraphicsWidget): def __init__(self, parent=None, line=QLineF(), text="", **kwargs): super().__init__(parent, **kwargs) self._text = text self.setFlag(pg.GraphicsObject.ItemHasNoContents) self._spine = QGraphicsLineItem(line, self) angle = line.angle() self._arrow = pg.ArrowItem(parent=self, angle=0) self._arrow.setPos(self._spine.line().p2()) self._arrow.setRotation(angle) self._arrow.setStyle(headLen=10) self._label = TextItem(text=text, color=(10, 10, 10)) self._label.setParentItem(self) self._label.setPos(*self.get_xy()) self._label.setColor(self.palette().color(QPalette.Text)) if parent is not None: self.setParentItem(parent) def get_xy(self): point = self._spine.line().p2() return point.x(), point.y() def setFont(self, font): self._label.setFont(font) def setText(self, text): if text != self._text: self._text = text self._label.setText(text) self._label.setVisible(bool(text)) def text(self): return self._text def setLine(self, *line): line = QLineF(*line) if line != self._spine.line(): self._spine.setLine(line) self.__updateLayout() def line(self): return self._spine.line() def setPen(self, pen): self._spine.setPen(pen) def setArrowVisible(self, visible): self._arrow.setVisible(visible) def paint(self, painter, option, widget): pass def boundingRect(self): return QRectF() def viewTransformChanged(self): self.__updateLayout() def __updateLayout(self): T = self.sceneTransform() if T is None: T = QTransform() # map the axis spine to scene coord. system. viewbox_line = T.map(self._spine.line()) angle = viewbox_line.angle() assert not np.isnan(angle) # note in Qt the y axis is inverted (90 degree angle 'points' down) left_quad = 270 < angle <= 360 or -0.0 <= angle < 90 # position the text label along the viewbox_line label_pos = self._spine.line().pointAt(0.90) if left_quad: # Anchor the text under the axis spine anchor = (0.5, -0.1) else: # Anchor the text over the axis spine anchor = (0.5, 1.1) self._label.setPos(label_pos) self._label.setAnchor(pg.Point(*anchor)) self._label.setRotation(-angle if left_quad else 180 - angle) self._arrow.setPos(self._spine.line().p2()) self._arrow.setRotation(180 - angle) def changeEvent(self, event): if event.type() == QEvent.PaletteChange: self._label.setColor(self.palette().color(QPalette.Text)) super().changeEvent(event)
class StripeItem(QGraphicsWidget): WIDTH = 70 def __init__(self, parent): super().__init__(parent) self.__range = None # type: Tuple[float] self.__value_range = None # type: Tuple[float] self.__model_output = None # type: float self.__base_value = None # type: float self.__group = QGraphicsItemGroup(self) low_color, high_color = QColor(*RGB_LOW), QColor(*RGB_HIGH) self.__low_item = QGraphicsRectItem() self.__low_item.setPen(QPen(low_color)) self.__low_item.setBrush(QBrush(low_color)) self.__high_item = QGraphicsRectItem() self.__high_item.setPen(QPen(high_color)) self.__high_item.setBrush(QBrush(high_color)) self.__low_cover_item = LowCoverItem() self.__high_cover_item = HighCoverItem() pen = QPen(IndicatorItem.COLOR) pen.setStyle(Qt.DashLine) pen.setWidth(1) self.__model_output_line = QGraphicsLineItem() self.__model_output_line.setPen(pen) self.__base_value_line = QGraphicsLineItem() self.__base_value_line.setPen(pen) self.__model_output_ind = IndicatorItem("Model prediction: {}") self.__base_value_ind = IndicatorItem("Base value: {}\nThe average " "prediction for selected class.") self.__group.addToGroup(self.__low_item) self.__group.addToGroup(self.__high_item) self.__group.addToGroup(self.__low_cover_item) self.__group.addToGroup(self.__high_cover_item) self.__group.addToGroup(self.__model_output_line) self.__group.addToGroup(self.__base_value_line) self.__group.addToGroup(self.__model_output_ind) self.__group.addToGroup(self.__base_value_ind) self.__low_parts = [] # type: List[LowPartItem] self.__high_parts = [] # type: List[HighPartItem] @property def total_width(self): widths = [ part.label_item.boundingRect().width() for part in self.__low_parts + self.__high_parts ] + [0] return self.WIDTH + StripePlot.SPACING + max(widths) @property def model_output_ind(self) -> IndicatorItem: return self.__model_output_ind @property def base_value_ind(self) -> IndicatorItem: return self.__base_value_ind def set_data(self, data: PlotData, y_range: Tuple[float, float], height: float): self.__range = y_range self.__value_range = data.value_range self.__model_output = data.model_output self.__base_value = data.base_value r = self.__range[1] - self.__range[0] self.__model_output_ind.set_text(self.__model_output, r) self.__base_value_ind.set_text(self.__base_value, r) for value, label in zip(data.low_values, data.low_labels): self.__add_part(value, label, value / sum(data.low_values), self.__low_parts, LowPartItem) for value, label in zip(data.high_values, data.high_labels): self.__add_part(value, label, value / sum(data.high_values), self.__high_parts, HighPartItem) if self.__low_parts: self.__low_parts[-1].setVisible(False) else: self.__low_item.setVisible(False) self.__low_cover_item.setVisible(False) if self.__high_parts: self.__high_parts[-1].setVisible(False) else: self.__high_item.setVisible(False) self.__high_cover_item.setVisible(False) self.set_z_values() self.set_height(height) def __add_part(self, value: float, label: Tuple[str, str], norm_val: float, list_: List[PartItem], cls_: Type[PartItem]): item = cls_(value, label, norm_val) list_.append(item) self.__group.addToGroup(item) self.__group.addToGroup(item.value_item) self.__group.addToGroup(item.label_item) def set_z_values(self): if len(self.__high_parts) < len(self.__low_parts): self.__high_cover_item.setZValue(-2) self.__high_item.setZValue(-3) for i, item in enumerate(self.__high_parts): item.setZValue(-1) else: self.__low_cover_item.setZValue(-2) self.__low_item.setZValue(-3) for i, item in enumerate(self.__low_parts): item.setZValue(-1) def set_height(self, height: float): if self.__range[1] == self.__range[0]: return height = height / (self.__range[1] - self.__range[0]) y_top = height * (self.__range[1] - self.__value_range[1]) h_top = height * (self.__value_range[1] - self.__model_output) y_bot = height * (self.__range[1] - self.__model_output) h_bot = height * (self.__model_output - self.__value_range[0]) self.__low_item.setRect(QRectF(0, y_top, self.WIDTH, h_top)) self.__high_item.setRect(QRectF(0, y_bot, self.WIDTH, h_bot)) self.__low_cover_item.setY(y_top) self.__high_cover_item.setY(y_bot + h_bot) self._set_indicators_pos(height) def adjust_y_text_low(i): # adjust label y according to TIP_LEN # adjust for 0.8 * TIP_LEN, because label is not 1 pixel wide k = 0.4 if i == len(self.__low_parts) - 1 else 0.8 return PartItem.TIP_LEN * k def adjust_y_text_high(i): k = 0.4 if i == 0 else 0.8 return -PartItem.TIP_LEN * k self._set_parts_pos(height, y_top, h_top / height, self.__low_parts, adjust_y_text_low) self._set_parts_pos(height, y_bot, h_bot / height, self.__high_parts, adjust_y_text_high) def _set_parts_pos(self, height: float, y: float, diff: float, parts: List[PartItem], adjust_y: Callable): for i, item in enumerate(parts): y_delta = height * item.norm_value * diff y_text = y + y_delta / 2 - item.value_height / 2 visible = y_delta > item.value_height + 8 y_test_adj = y_text + adjust_y(i) y_mid = height * (self.__range[1] - self.__model_output) collides = _collides( y_mid, y_mid, y_test_adj, y_test_adj + item.value_item.boundingRect().height()) item.value_item.setVisible(visible and not collides) item.value_item.setY(y_test_adj) item.label_item.setVisible(visible) item.label_item.setY(y_text) y = y + y_delta item.setY(y) def _set_indicators_pos(self, height: float): mo_y = height * (self.__range[1] - self.__model_output) mo_h = self.__model_output_ind.boundingRect().height() self.__model_output_ind.setY(mo_y - mo_h / 2) self.__model_output_line.setLine( 0, mo_y, -StripePlot.SPACING - IndicatorItem.MARGIN, mo_y) bv_y = height * (self.__range[1] - self.__base_value) bv_h = self.__base_value_ind.boundingRect().height() self.__base_value_ind.setY(bv_y - bv_h / 2) self.__base_value_line.setLine( -StripePlot.SPACING, bv_y, -StripePlot.SPACING - IndicatorItem.MARGIN, bv_y) collides = _collides(mo_y, mo_y + mo_h, bv_y, bv_y + bv_h, d=6) self.__base_value_ind.setVisible(not collides)
class OWNomogram(OWWidget): name = "列线图" description = "朴素贝叶斯可视化的诺莫图和逻辑回归分类器" icon = "icons/Nomogram.svg" priority = 2000 keywords = [] class Inputs: classifier = Input("分类器", Model) data = Input("数据", Table) MAX_N_ATTRS = 1000 POINT_SCALE = 0 ALIGN_LEFT = 0 ALIGN_ZERO = 1 ACCEPTABLE = (NaiveBayesModel, LogisticRegressionClassifier) settingsHandler = ClassValuesContextHandler() target_class_index = ContextSetting(0) normalize_probabilities = Setting(False) scale = Setting(1) display_index = Setting(1) n_attributes = Setting(10) sort_index = Setting(SortBy.ABSOLUTE) cont_feature_dim_index = Setting(0) graph_name = "场景" class Error(OWWidget.Error): invalid_classifier = Msg("诺莫图只接受朴素贝叶斯和逻辑回归分类器") def __init__(self): super().__init__() self.instances = None self.domain = None self.data = None self.classifier = None self.align = OWNomogram.ALIGN_ZERO self.log_odds_ratios = [] self.log_reg_coeffs = [] self.log_reg_coeffs_orig = [] self.log_reg_cont_data_extremes = [] self.p = None self.b0 = None self.points = [] self.feature_items = {} self.feature_marker_values = [] self.scale_marker_values = lambda x: x self.nomogram_main = None self.vertical_line = None self.hidden_vertical_line = None self.old_target_class_index = self.target_class_index self.repaint = False # GUI box = gui.vBox(self.controlArea, "目标类") self.class_combo = gui.comboBox( box, self, "target_class_index", callback=self._class_combo_changed, contentsLength=12) self.norm_check = gui.checkBox( box, self, "normalize_probabilities", "Normalize probabilities", hidden=True, callback=self.update_scene, tooltip="For multiclass data 1 vs. all probabilities do not" " sum to 1 and therefore could be normalized.") self.scale_radio = gui.radioButtons( self.controlArea, self, "scale", ["点量表", "记录比值比"], box="规模", callback=self.update_scene) box = gui.vBox(self.controlArea, "显示特征") grid = QGridLayout() radio_group = gui.radioButtonsInBox( box, self, "display_index", [], orientation=grid, callback=self.update_scene) radio_all = gui.appendRadioButton( radio_group, "所有", addToLayout=False) radio_best = gui.appendRadioButton( radio_group, "最佳排名:", addToLayout=False) spin_box = gui.hBox(None, margin=0) self.n_spin = gui.spin( spin_box, self, "n_attributes", 1, self.MAX_N_ATTRS, label=" ", controlWidth=60, callback=self._n_spin_changed) grid.addWidget(radio_all, 1, 1) grid.addWidget(radio_best, 2, 1) grid.addWidget(spin_box, 2, 2) self.sort_combo = gui.comboBox( box, self, "sort_index", label="排名靠前:", items=SortBy.items(), orientation=Qt.Horizontal, callback=self.update_scene) self.cont_feature_dim_combo = gui.comboBox( box, self, "cont_feature_dim_index", label="数字特征: ", items=["1维投影", "二维曲线"], orientation=Qt.Horizontal, callback=self.update_scene) gui.rubber(self.controlArea) class _GraphicsView(QGraphicsView): def __init__(self, scene, parent, **kwargs): for k, v in dict(verticalScrollBarPolicy=Qt.ScrollBarAlwaysOff, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, viewportUpdateMode=QGraphicsView.BoundingRectViewportUpdate, renderHints=(QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform), alignment=(Qt.AlignTop | Qt.AlignLeft), sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)).items(): kwargs.setdefault(k, v) super().__init__(scene, parent, **kwargs) class GraphicsView(_GraphicsView): def __init__(self, scene, parent): super().__init__(scene, parent, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, styleSheet='QGraphicsView {background: white}') self.viewport().setMinimumWidth(300) # XXX: This prevents some tests failing self._is_resizing = False w = self def resizeEvent(self, resizeEvent): # Recompute main scene on window width change if resizeEvent.size().width() != resizeEvent.oldSize().width(): self._is_resizing = True self.w.update_scene() self._is_resizing = False return super().resizeEvent(resizeEvent) def is_resizing(self): return self._is_resizing def sizeHint(self): return QSize(400, 200) class FixedSizeGraphicsView(_GraphicsView): def __init__(self, scene, parent): super().__init__(scene, parent, sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)) def sizeHint(self): return QSize(400, 85) scene = self.scene = QGraphicsScene(self) top_view = self.top_view = FixedSizeGraphicsView(scene, self) mid_view = self.view = GraphicsView(scene, self) bottom_view = self.bottom_view = FixedSizeGraphicsView(scene, self) for view in (top_view, mid_view, bottom_view): self.mainArea.layout().addWidget(view) def _class_combo_changed(self): with np.errstate(invalid='ignore'): coeffs = [np.nan_to_num(p[self.target_class_index] / p[self.old_target_class_index]) for p in self.points] points = [p[self.old_target_class_index] for p in self.points] self.feature_marker_values = [ self.get_points_from_coeffs(v, c, p) for (v, c, p) in zip(self.feature_marker_values, coeffs, points)] self.feature_marker_values = np.asarray(self.feature_marker_values) self.update_scene() self.old_target_class_index = self.target_class_index def _n_spin_changed(self): self.display_index = 1 self.update_scene() def update_controls(self): self.class_combo.clear() self.norm_check.setHidden(True) self.cont_feature_dim_combo.setEnabled(True) if self.domain is not None: self.class_combo.addItems(self.domain.class_vars[0].values) if len(self.domain.attributes) > self.MAX_N_ATTRS: self.display_index = 1 if len(self.domain.class_vars[0].values) > 2: self.norm_check.setHidden(False) if not self.domain.has_continuous_attributes(): self.cont_feature_dim_combo.setEnabled(False) self.cont_feature_dim_index = 0 model = self.sort_combo.model() item = model.item(SortBy.POSITIVE) item.setFlags(item.flags() | Qt.ItemIsEnabled) item = model.item(SortBy.NEGATIVE) item.setFlags(item.flags() | Qt.ItemIsEnabled) self.align = OWNomogram.ALIGN_ZERO if self.classifier and isinstance(self.classifier, LogisticRegressionClassifier): self.align = OWNomogram.ALIGN_LEFT @Inputs.data def set_data(self, data): self.instances = data self.feature_marker_values = [] self.set_feature_marker_values() self.update_scene() @Inputs.classifier def set_classifier(self, classifier): self.closeContext() self.classifier = classifier self.Error.clear() if self.classifier and not isinstance(self.classifier, self.ACCEPTABLE): self.Error.invalid_classifier() self.classifier = None self.domain = self.classifier.domain if self.classifier else None self.data = None self.calculate_log_odds_ratios() self.calculate_log_reg_coefficients() self.update_controls() self.target_class_index = 0 self.openContext(self.domain.class_var if self.domain is not None else None) self.points = self.log_odds_ratios or self.log_reg_coeffs self.feature_marker_values = [] self.old_target_class_index = self.target_class_index self.update_scene() def calculate_log_odds_ratios(self): self.log_odds_ratios = [] self.p = None if self.classifier is None or self.domain is None: return if not isinstance(self.classifier, NaiveBayesModel): return log_cont_prob = self.classifier.log_cont_prob class_prob = self.classifier.class_prob for i in range(len(self.domain.attributes)): ca = np.exp(log_cont_prob[i]) * class_prob[:, None] _or = (ca / (1 - ca)) / (class_prob / (1 - class_prob))[:, None] self.log_odds_ratios.append(np.log(_or)) self.p = class_prob def calculate_log_reg_coefficients(self): self.log_reg_coeffs = [] self.log_reg_cont_data_extremes = [] self.b0 = None if self.classifier is None or self.domain is None: return if not isinstance(self.classifier, LogisticRegressionClassifier): return self.domain = self.reconstruct_domain(self.classifier.original_domain, self.domain) self.data = self.classifier.original_data.transform(self.domain) attrs, ranges, start = self.domain.attributes, [], 0 for attr in attrs: stop = start + len(attr.values) if attr.is_discrete else start + 1 ranges.append(slice(start, stop)) start = stop self.b0 = self.classifier.intercept coeffs = self.classifier.coefficients if len(self.domain.class_var.values) == 2: self.b0 = np.hstack((self.b0 * (-1), self.b0)) coeffs = np.vstack((coeffs * (-1), coeffs)) self.log_reg_coeffs = [coeffs[:, ranges[i]] for i in range(len(attrs))] self.log_reg_coeffs_orig = self.log_reg_coeffs.copy() min_values = nanmin(self.data.X, axis=0) max_values = nanmax(self.data.X, axis=0) for i, min_t, max_t in zip(range(len(self.log_reg_coeffs)), min_values, max_values): if self.log_reg_coeffs[i].shape[1] == 1: coef = self.log_reg_coeffs[i] self.log_reg_coeffs[i] = np.hstack((coef * min_t, coef * max_t)) self.log_reg_cont_data_extremes.append( [sorted([min_t, max_t], reverse=(c < 0)) for c in coef]) else: self.log_reg_cont_data_extremes.append([None]) def update_scene(self): self.clear_scene() if self.domain is None or not len(self.points[0]): return n_attrs = self.n_attributes if self.display_index else int(1e10) attr_inds, attributes = zip(*self.get_ordered_attributes()[:n_attrs]) name_items = [QGraphicsTextItem(attr.name) for attr in attributes] point_text = QGraphicsTextItem("Points") probs_text = QGraphicsTextItem("Probabilities (%)") all_items = name_items + [point_text, probs_text] name_offset = -max(t.boundingRect().width() for t in all_items) - 10 w = self.view.viewport().rect().width() max_width = w + name_offset - 30 points = [self.points[i][self.target_class_index] for i in attr_inds] if self.align == OWNomogram.ALIGN_LEFT: points = [p - p.min() for p in points] max_ = np.nan_to_num(max(max(abs(p)) for p in points)) d = 100 / max_ if max_ else 1 minimums = [p[self.target_class_index].min() for p in self.points] if self.scale == OWNomogram.POINT_SCALE: points = [p * d for p in points] if self.align == OWNomogram.ALIGN_LEFT: self.scale_marker_values = lambda x: (x - minimums) * d else: self.scale_marker_values = lambda x: x * d else: if self.align == OWNomogram.ALIGN_LEFT: self.scale_marker_values = lambda x: x - minimums else: self.scale_marker_values = lambda x: x point_item, nomogram_head = self.create_main_nomogram( attributes, attr_inds, name_items, points, max_width, point_text, name_offset) probs_item, nomogram_foot = self.create_footer_nomogram( probs_text, d, minimums, max_width, name_offset) for item in self.feature_items.values(): item.dot.point_dot = point_item.dot item.dot.probs_dot = probs_item.dot item.dot.vertical_line = self.hidden_vertical_line self.nomogram = nomogram = NomogramItem() nomogram.add_items([nomogram_head, self.nomogram_main, nomogram_foot]) self.scene.addItem(nomogram) self.set_feature_marker_values() rect = QRectF(self.scene.itemsBoundingRect().x(), self.scene.itemsBoundingRect().y(), self.scene.itemsBoundingRect().width(), self.nomogram.preferredSize().height()).adjusted(10, 0, 20, 0) self.scene.setSceneRect(rect) # Clip top and bottom (60 and 150) parts from the main view self.view.setSceneRect(rect.x(), rect.y() + 80, rect.width() - 10, rect.height() - 160) self.view.viewport().setMaximumHeight(rect.height() - 160) # Clip main part from top/bottom views # below point values are imprecise (less/more than required) but this # is not a problem due to clipped scene content still being drawn self.top_view.setSceneRect(rect.x(), rect.y() + 3, rect.width() - 10, 20) self.bottom_view.setSceneRect(rect.x(), rect.height() - 110, rect.width() - 10, 30) def create_main_nomogram(self, attributes, attr_inds, name_items, points, max_width, point_text, name_offset): cls_index = self.target_class_index min_p = min(p.min() for p in points) max_p = max(p.max() for p in points) values = self.get_ruler_values(min_p, max_p, max_width) min_p, max_p = min(values), max(values) diff_ = np.nan_to_num(max_p - min_p) scale_x = max_width / diff_ if diff_ else max_width nomogram_header = NomogramItem() point_item = RulerItem(point_text, values, scale_x, name_offset, - scale_x * min_p) point_item.setPreferredSize(point_item.preferredWidth(), 35) nomogram_header.add_items([point_item]) self.nomogram_main = NomogramItem() cont_feature_item_class = ContinuousFeature2DItem if \ self.cont_feature_dim_index else ContinuousFeatureItem feature_items = [ DiscreteFeatureItem( name_item, attr.values, point, scale_x, name_offset, - scale_x * min_p) if attr.is_discrete else cont_feature_item_class( name_item, self.log_reg_cont_data_extremes[i][cls_index], self.get_ruler_values( point.min(), point.max(), scale_x * point.ptp(), False), scale_x, name_offset, - scale_x * min_p) for i, attr, name_item, point in zip(attr_inds, attributes, name_items, points)] self.nomogram_main.add_items(feature_items) self.feature_items = OrderedDict(sorted(zip(attr_inds, feature_items))) x = - scale_x * min_p y = self.nomogram_main.layout().preferredHeight() + 10 self.vertical_line = QGraphicsLineItem(x, -6, x, y) self.vertical_line.setPen(QPen(Qt.DotLine)) self.vertical_line.setParentItem(point_item) self.hidden_vertical_line = QGraphicsLineItem(x, -6, x, y) pen = QPen(Qt.DashLine) pen.setBrush(QColor(Qt.red)) self.hidden_vertical_line.setPen(pen) self.hidden_vertical_line.setParentItem(point_item) return point_item, nomogram_header def get_ordered_attributes(self): """Return (in_domain_index, attr) pairs, ordered by method in SortBy combo""" if self.domain is None or not self.domain.attributes: return [] attrs = self.domain.attributes sort_by = self.sort_index class_value = self.target_class_index if sort_by == SortBy.NO_SORTING: return list(enumerate(attrs)) elif sort_by == SortBy.NAME: def key(x): _, attr = x return attr.name.lower() elif sort_by == SortBy.ABSOLUTE: def key(x): i, attr = x if attr.is_discrete: ptp = self.points[i][class_value].ptp() else: coef = np.abs(self.log_reg_coeffs_orig[i][class_value]).mean() ptp = coef * np.ptp(self.log_reg_cont_data_extremes[i][class_value]) return -ptp elif sort_by == SortBy.POSITIVE: def key(x): i, attr = x max_value = (self.points[i][class_value].max() if attr.is_discrete else np.mean(self.log_reg_cont_data_extremes[i][class_value])) return -max_value elif sort_by == SortBy.NEGATIVE: def key(x): i, attr = x min_value = (self.points[i][class_value].min() if attr.is_discrete else np.mean(self.log_reg_cont_data_extremes[i][class_value])) return min_value return sorted(enumerate(attrs), key=key) def create_footer_nomogram(self, probs_text, d, minimums, max_width, name_offset): # pylint: disable=invalid-unary-operand-type eps, d_ = 0.05, 1 k = - np.log(self.p / (1 - self.p)) if self.p is not None else - self.b0 min_sum = k[self.target_class_index] - np.log((1 - eps) / eps) max_sum = k[self.target_class_index] - np.log(eps / (1 - eps)) if self.align == OWNomogram.ALIGN_LEFT: max_sum = max_sum - sum(minimums) min_sum = min_sum - sum(minimums) for i in range(len(k)): # pylint: disable=consider-using-enumerate k[i] = k[i] - sum([min(q) for q in [p[i] for p in self.points]]) if self.scale == OWNomogram.POINT_SCALE: min_sum *= d max_sum *= d d_ = d values = self.get_ruler_values(min_sum, max_sum, max_width) min_sum, max_sum = min(values), max(values) diff_ = np.nan_to_num(max_sum - min_sum) scale_x = max_width / diff_ if diff_ else max_width cls_var, cls_index = self.domain.class_var, self.target_class_index nomogram_footer = NomogramItem() def get_normalized_probabilities(val): if not self.normalize_probabilities: return 1 / (1 + np.exp(k[cls_index] - val / d_)) totals = self.__get_totals_for_class_values(minimums) p_sum = np.sum(1 / (1 + np.exp(k - totals / d_))) return 1 / (1 + np.exp(k[cls_index] - val / d_)) / p_sum def get_points(prob): if not self.normalize_probabilities: return (k[cls_index] - np.log(1 / prob - 1)) * d_ totals = self.__get_totals_for_class_values(minimums) p_sum = np.sum(1 / (1 + np.exp(k - totals / d_))) return (k[cls_index] - np.log(1 / (prob * p_sum) - 1)) * d_ probs_item = ProbabilitiesRulerItem( probs_text, values, scale_x, name_offset, - scale_x * min_sum, get_points=get_points, title="{}='{}'".format(cls_var.name, cls_var.values[cls_index]), get_probabilities=get_normalized_probabilities) nomogram_footer.add_items([probs_item]) return probs_item, nomogram_footer def __get_totals_for_class_values(self, minimums): cls_index = self.target_class_index marker_values = self.scale_marker_values(self.feature_marker_values) totals = np.full(len(self.domain.class_var.values), np.nan) totals[cls_index] = marker_values.sum() for i in range(len(self.domain.class_var.values)): if i == cls_index: continue coeffs = [np.nan_to_num(p[i] / p[cls_index]) for p in self.points] points = [p[cls_index] for p in self.points] total = sum([self.get_points_from_coeffs(v, c, p) for (v, c, p) in zip(self.feature_marker_values, coeffs, points)]) if self.align == OWNomogram.ALIGN_LEFT: points = [p - m for m, p in zip(minimums, points)] total -= sum([min(p) for p in [p[i] for p in self.points]]) d = 100 / max(max(abs(p)) for p in points) if self.scale == OWNomogram.POINT_SCALE: total *= d totals[i] = total assert not np.any(np.isnan(totals)) return totals def set_feature_marker_values(self): if not (len(self.points) and len(self.feature_items)): return if not len(self.feature_marker_values): self._init_feature_marker_values() marker_values = self.scale_marker_values(self.feature_marker_values) invisible_sum = 0 for i, marker in enumerate(marker_values): try: item = self.feature_items[i] except KeyError: invisible_sum += marker else: item.dot.move_to_val(marker) item.dot.probs_dot.move_to_sum(invisible_sum) def _init_feature_marker_values(self): self.feature_marker_values = [] cls_index = self.target_class_index instances = Table(self.domain, self.instances) \ if self.instances else None values = [] for i, attr in enumerate(self.domain.attributes): value, feature_val = 0, None if len(self.log_reg_coeffs): if attr.is_discrete: ind, n = unique(self.data.X[:, i], return_counts=True) feature_val = np.nan_to_num(ind[np.argmax(n)]) else: feature_val = nanmean(self.data.X[:, i]) # If data is provided on a separate signal, use the first data # instance to position the points instead of the mean inst_in_dom = instances and attr in instances.domain if inst_in_dom and not np.isnan(instances[0][attr]): feature_val = instances[0][attr] if feature_val is not None: value = (self.points[i][cls_index][int(feature_val)] if attr.is_discrete else self.log_reg_coeffs_orig[i][cls_index][0] * feature_val) values.append(value) self.feature_marker_values = np.asarray(values) def clear_scene(self): self.feature_items = {} self.scale_marker_values = lambda x: x self.nomogram = None self.nomogram_main = None self.vertical_line = None self.hidden_vertical_line = None self.scene.clear() def send_report(self): self.report_plot() @staticmethod def reconstruct_domain(original, preprocessed): # abuse dict to make "in" comparisons faster attrs = OrderedDict() for attr in preprocessed.attributes: cv = attr._compute_value.variable._compute_value var = cv.variable if cv else original[attr.name] if var in attrs: # the reason for OrderedDict continue attrs[var] = None # we only need keys attrs = list(attrs.keys()) return Domain(attrs, original.class_var, original.metas) @staticmethod def get_ruler_values(start, stop, max_width, round_to_nearest=True): if max_width == 0: return [0] diff = np.nan_to_num((stop - start) / max_width) if diff <= 0: return [0] decimals = int(np.floor(np.log10(diff))) if diff > 4 * pow(10, decimals): step = 5 * pow(10, decimals + 2) elif diff > 2 * pow(10, decimals): step = 2 * pow(10, decimals + 2) elif diff > 1 * pow(10, decimals): step = 1 * pow(10, decimals + 2) else: step = 5 * pow(10, decimals + 1) round_by = int(- np.floor(np.log10(step))) r = start % step if not round_to_nearest: _range = np.arange(start + step, stop + r, step) - r start, stop = np.floor(start * 100) / 100, np.ceil(stop * 100) / 100 return np.round(np.hstack((start, _range, stop)), 2) return np.round(np.arange(start, stop + r + step, step) - r, round_by) @staticmethod def get_points_from_coeffs(current_value, coefficients, possible_values): if np.isnan(possible_values).any(): return 0 # pylint: disable=undefined-loop-variable indices = np.argsort(possible_values) sorted_values = possible_values[indices] sorted_coefficients = coefficients[indices] for i, val in enumerate(sorted_values): if current_value < val: break diff = sorted_values[i] - sorted_values[i - 1] k = 0 if diff < 1e-6 else (sorted_values[i] - current_value) / \ (sorted_values[i] - sorted_values[i - 1]) return sorted_coefficients[i - 1] * sorted_values[i - 1] * k + \ sorted_coefficients[i] * sorted_values[i] * (1 - k)