def create_legend(self): if self.legend is not None: self.scene.removeItem(self.legend) self.legend = None if self.attr_color is None: return if self.attr_color.is_discrete: names = self.attr_color.values else: names = self._bin_names() items = [] size = 8 for name, color in zip(names, self.colors.qcolors): item = QGraphicsItemGroup() item.addToGroup( CanvasRectangle(None, -size / 2, -size / 2, size, size, Qt.gray, color)) item.addToGroup(CanvasText(None, name, size, 0, Qt.AlignVCenter)) items.append(item) self.legend = wrap_legend_items(items, hspacing=20, vspacing=16 + size, max_width=self.view.width() - 25) self.legend.setFlags(self.legend.ItemIgnoresTransformations) self.legend.setTransform( QTransform.fromTranslate(-self.legend.boundingRect().width() / 2, 0)) self.scene.addItem(self.legend) self.set_legend_pos()
def create_legend(): if self.variable_color is None: names = [ "<-8", "-8:-4", "-4:-2", "-2:2", "2:4", "4:8", ">8", "Residuals:" ] colors = self.RED_COLORS[::-1] + self.BLUE_COLORS[1:] edges = repeat(Qt.black) else: names = get_variable_values_sorted(class_var) edges = colors = [QColor(*col) for col in class_var.colors] items = [] size = 8 for name, color, edgecolor in zip(names, colors, edges): item = QGraphicsItemGroup() item.addToGroup( CanvasRectangle(None, -size / 2, -size / 2, size, size, edgecolor, color)) item.addToGroup( CanvasText(None, name, size, 0, Qt.AlignVCenter)) items.append(item) return wrap_legend_items(items, hspacing=20, vspacing=16 + size, max_width=self.canvas_view.width() - xoff)
def strudel(self, dist): attr = self.attribute ss = np.sum(dist) box = QGraphicsItemGroup() if ss < 1e-6: QGraphicsRectItem(0, -10, 1, 10, box) cum = 0 for i, v in enumerate(dist): if v < 1e-6: continue if self.stretched: v /= ss v *= self.scale_x rect = QGraphicsRectItem(cum + 1, -6, v - 2, 12, box) rect.setBrush(QBrush(QColor(*attr.colors[i]))) rect.setPen(QPen(Qt.NoPen)) if self.stretched: tooltip = "{}: {:.2f}%".format(attr.values[i], 100 * dist[i] / sum(dist)) else: tooltip = "{}: {}".format(attr.values[i], int(dist[i])) rect.setToolTip(tooltip) text = QGraphicsTextItem(attr.values[i]) box.addToGroup(text) cum += v return box
def strudel(self, dist, group_val_index=None): attr = self.attribute ss = np.sum(dist) box = QGraphicsItemGroup() if ss < 1e-6: cond = [FilterDiscrete(attr, None)] if group_val_index is not None: cond.append(FilterDiscrete(self.group_var, [group_val_index])) FilterGraphicsRectItem(cond, 0, -10, 1, 10, box) cum = 0 for i, v in enumerate(dist): if v < 1e-6: continue if self.stretched: v /= ss v *= self.scale_x cond = [FilterDiscrete(attr, [i])] if group_val_index is not None: cond.append(FilterDiscrete(self.group_var, [group_val_index])) rect = FilterGraphicsRectItem(cond, cum + 1, -6, v - 2, 12, box) rect.setBrush(QBrush(QColor(*attr.colors[i]))) rect.setPen(QPen(Qt.NoPen)) if self.stretched: tooltip = "{}: {:.2f}%".format(attr.values[i], 100 * dist[i] / sum(dist)) else: tooltip = "{}: {}".format(attr.values[i], int(dist[i])) rect.setToolTip(tooltip) text = QGraphicsTextItem(attr.values[i]) box.addToGroup(text) cum += v return box
def label_group(self, stat, attr, mean_lab): def centered_text(val, pos): t = QGraphicsSimpleTextItem( "%.*f" % (attr.number_of_decimals + 1, val), labels ) t.setFont(self._label_font) bbox = t.boundingRect() t.setPos(pos - bbox.width() / 2, 22) return t def line(x, down=1): QGraphicsLineItem(x, 12 * down, x, 20 * down, labels) def move_label(label, frm, to): label.setX(to) to += t_box.width() / 2 path = QPainterPath() path.lineTo(0, 4) path.lineTo(to - frm, 4) path.lineTo(to - frm, 8) p = QGraphicsPathItem(path) p.setPos(frm, 12) labels.addToGroup(p) labels = QGraphicsItemGroup() labels.addToGroup(mean_lab) m = stat.mean * self.scale_x mean_lab.setPos(m, -22) line(m, -1) if stat.median is not None: msc = stat.median * self.scale_x med_t = centered_text(stat.median, msc) med_box_width2 = med_t.boundingRect().width() line(msc) if stat.q25 is not None: x = stat.q25 * self.scale_x t = centered_text(stat.q25, x) t_box = t.boundingRect() med_left = msc - med_box_width2 if x + t_box.width() / 2 >= med_left - 5: move_label(t, x, med_left - t_box.width() - 5) else: line(x) if stat.q75 is not None: x = stat.q75 * self.scale_x t = centered_text(stat.q75, x) t_box = t.boundingRect() med_right = msc + med_box_width2 if x - t_box.width() / 2 <= med_right + 5: move_label(t, x, med_right + 5) else: line(x) return labels
def label_group(self, stat, attr, mean_lab): def centered_text(val, pos): t = QGraphicsSimpleTextItem( "%.*f" % (attr.number_of_decimals + 1, val), labels) t.setFont(self._label_font) bbox = t.boundingRect() t.setPos(pos - bbox.width() / 2, 22) return t def line(x, down=1): QGraphicsLineItem(x, 12 * down, x, 20 * down, labels) def move_label(label, frm, to): label.setX(to) to += t_box.width() / 2 path = QPainterPath() path.lineTo(0, 4) path.lineTo(to - frm, 4) path.lineTo(to - frm, 8) p = QGraphicsPathItem(path) p.setPos(frm, 12) labels.addToGroup(p) labels = QGraphicsItemGroup() labels.addToGroup(mean_lab) m = stat.mean * self.scale_x mean_lab.setPos(m, -22) line(m, -1) if stat.median is not None: msc = stat.median * self.scale_x med_t = centered_text(stat.median, msc) med_box_width2 = med_t.boundingRect().width() / 2 line(msc) if stat.q25 is not None: x = stat.q25 * self.scale_x t = centered_text(stat.q25, x) t_box = t.boundingRect() med_left = msc - med_box_width2 if x + t_box.width() / 2 >= med_left - 5: move_label(t, x, med_left - t_box.width() - 5) else: line(x) if stat.q75 is not None: x = stat.q75 * self.scale_x t = centered_text(stat.q75, x) t_box = t.boundingRect() med_right = msc + med_box_width2 if x - t_box.width() / 2 <= med_right + 5: move_label(t, x, med_right + 5) else: line(x) return labels
class Legend(QGraphicsWidget): WIDTH = 30 BAR_WIDTH = 7 def __init__(self, parent): super().__init__(parent) self.__offset = 2 self.__group = QGraphicsItemGroup(self) self.__bar_height = ViolinItem.HEIGHT * 3 self._add_bar() self._add_high_label() self._add_low_label() self._add_feature_label() def _add_bar(self): item = QGraphicsRectItem(0, 0, self.BAR_WIDTH, self.__bar_height) gradient = QLinearGradient(0, 0, 0, self.__bar_height) gradient.setColorAt(0, QColor(*RGB_HIGH)) gradient.setColorAt(1, QColor(*RGB_LOW)) item.setPen(QPen(Qt.NoPen)) item.setBrush(gradient) self.__group.addToGroup(item) def _add_high_label(self): font = self.font() font.setPixelSize(9) item = QGraphicsSimpleTextItem("High") item.setFont(font) item.setX(self.BAR_WIDTH + self.__offset) item.setY(0) self.__group.addToGroup(item) def _add_low_label(self): font = self.font() font.setPixelSize(9) item = QGraphicsSimpleTextItem("Low") item.setFont(font) item.setX(self.BAR_WIDTH + self.__offset) item.setY(self.__bar_height - item.boundingRect().height()) self.__group.addToGroup(item) def _add_feature_label(self): font = self.font() font.setPixelSize(11) item = QGraphicsSimpleTextItem("Feature value") item.setRotation(-90) item.setFont(font) item.setX(self.BAR_WIDTH + self.__offset * 2) item.setY(self.__bar_height / 2 + item.boundingRect().width() / 2) self.__group.addToGroup(item) def sizeHint(self, *_): return QSizeF(self.WIDTH, ViolinItem.HEIGHT)
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 OWSOM(OWWidget): name = "Self-Organizing Map" description = "Computation of self-organizing map." icon = "icons/SOM.svg" keywords = ["SOM"] class Inputs: data = Input("Data", Table) class Outputs: selected_data = Output("Selected Data", Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) settingsHandler = DomainContextHandler() auto_dimension = Setting(True) size_x = Setting(10) size_y = Setting(10) hexagonal = Setting(1) initialization = Setting(0) attr_color = ContextSetting(None) size_by_instances = Setting(True) pie_charts = Setting(False) selection = Setting(None, schema_only=True) graph_name = "view" _grid_pen = QPen(QBrush(QColor(224, 224, 224)), 2) _grid_pen.setCosmetic(True) OptControls = namedtuple( "OptControls", ("shape", "auto_dim", "spin_x", "spin_y", "initialization", "start")) class Warning(OWWidget.Warning): ignoring_disc_variables = Msg("SOM ignores discrete variables.") missing_colors = \ Msg("Some data instances have undefined value of '{}'.") missing_values = \ Msg("{} data instance{} with undefined value(s) {} not shown.") single_attribute = Msg("Data contains a single numeric column.") class Error(OWWidget.Error): no_numeric_variables = Msg("Data contains no numeric columns.") no_defined_rows = Msg("All rows contain at least one undefined value.") def __init__(self): super().__init__() self.__pending_selection = self.selection self._optimizer = None self._optimizer_thread = None self.stop_optimization = False self.data = self.cont_x = None self.cells = self.member_data = None self.selection = None self.colors = self.thresholds = self.bin_labels = None self._set_input_summary(None) self._set_output_summary(None) box = gui.vBox(self.controlArea, box="SOM") shape = gui.comboBox(box, self, "", items=("Hexagonal grid", "Square grid")) shape.setCurrentIndex(1 - self.hexagonal) box2 = gui.indentedBox(box, 10) auto_dim = gui.checkBox(box2, self, "auto_dimension", "Set dimensions automatically", callback=self.on_auto_dimension_changed) self.manual_box = box3 = gui.hBox(box2) spinargs = dict(value="", widget=box3, master=self, minv=5, maxv=100, step=5, alignment=Qt.AlignRight) spin_x = gui.spin(**spinargs) spin_x.setValue(self.size_x) gui.widgetLabel(box3, "×") spin_y = gui.spin(**spinargs) spin_y.setValue(self.size_y) gui.rubber(box3) self.manual_box.setEnabled(not self.auto_dimension) initialization = gui.comboBox(box, self, "initialization", items=("Initialize with PCA", "Random initialization", "Replicable random")) start = gui.button(box, self, "Restart", callback=self.restart_som_pressed, sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)) self.opt_controls = self.OptControls(shape, auto_dim, spin_x, spin_y, initialization, start) box = gui.vBox(self.controlArea, "Color") gui.comboBox(box, self, "attr_color", searchable=True, callback=self.on_attr_color_change, model=DomainModel(placeholder="(Same color)", valid_types=DomainModel.PRIMITIVE)) gui.checkBox(box, self, "pie_charts", label="Show pie charts", callback=self.on_pie_chart_change) gui.checkBox(box, self, "size_by_instances", label="Size by number of instances", callback=self.on_attr_size_change) gui.rubber(self.controlArea) self.scene = QGraphicsScene(self) self.view = SomView(self.scene) self.view.setMinimumWidth(400) self.view.setMinimumHeight(400) self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.view.setRenderHint(QPainter.Antialiasing) self.view.selection_changed.connect(self.on_selection_change) self.view.selection_moved.connect(self.on_selection_move) self.view.selection_mark_changed.connect(self.on_selection_mark_change) self.mainArea.layout().addWidget(self.view) self.elements = None self.grid = None self.grid_cells = None self.legend = None @Inputs.data def set_data(self, data): def prepare_data(): if len(cont_attrs) < len(attrs): self.Warning.ignoring_disc_variables() if len(cont_attrs) == 1: self.Warning.single_attribute() x = Table.from_table(Domain(cont_attrs), data).X if sp.issparse(x): self.data = data self.cont_x = x.tocsr() else: mask = np.all(np.isfinite(x), axis=1) if not np.any(mask): self.Error.no_defined_rows() else: if np.all(mask): self.data = data self.cont_x = x.copy() else: self.data = data[mask] self.cont_x = x[mask] self.cont_x -= np.min(self.cont_x, axis=0)[None, :] sums = np.sum(self.cont_x, axis=0)[None, :] sums[sums == 0] = 1 self.cont_x /= sums def set_warnings(): missing = len(data) - len(self.data) if missing == 1: self.Warning.missing_values(1, "", "is") elif missing > 1: self.Warning.missing_values(missing, "s", "are") self.stop_optimization_and_wait() self.closeContext() self.clear() self.Error.clear() self.Warning.clear() if data is not None: attrs = data.domain.attributes cont_attrs = [var for var in attrs if var.is_continuous] if not cont_attrs: self.Error.no_numeric_variables() else: prepare_data() if self.data is not None: self.controls.attr_color.model().set_domain(data.domain) self.attr_color = data.domain.class_var set_warnings() self.openContext(self.data) self.set_color_bins() self.create_legend() self.recompute_dimensions() self._set_input_summary(data) self.start_som() def _set_input_summary(self, data): summary = len(data) if data else self.info.NoInput details = format_summary_details(data) if data else "" self.info.set_input_summary(summary, details) def _set_output_summary(self, output): summary = len(output) if output else self.info.NoOutput details = format_summary_details(output) if output else "" self.info.set_output_summary(summary, details) def clear(self): self.data = self.cont_x = None self.cells = self.member_data = None self.attr_color = None self.colors = self.thresholds = self.bin_labels = None if self.elements is not None: self.scene.removeItem(self.elements) self.elements = None self.clear_selection() self.controls.attr_color.model().set_domain(None) self.Warning.clear() self.Error.clear() def recompute_dimensions(self): if not self.auto_dimension or self.cont_x is None: return dim = max(5, int(np.ceil(np.sqrt(5 * np.sqrt(self.cont_x.shape[0]))))) self.opt_controls.spin_x.setValue(dim) self.opt_controls.spin_y.setValue(dim) def on_auto_dimension_changed(self): self.manual_box.setEnabled(not self.auto_dimension) if self.auto_dimension: self.recompute_dimensions() else: spin_x = self.opt_controls.spin_x spin_y = self.opt_controls.spin_y dimx = int(5 * np.round(spin_x.value() / 5)) dimy = int(5 * np.round(spin_y.value() / 5)) spin_x.setValue(dimx) spin_y.setValue(dimy) def on_attr_color_change(self): self.controls.pie_charts.setEnabled(self.attr_color is not None) self.set_color_bins() self.create_legend() self.rescale() self._redraw() def on_attr_size_change(self): self._redraw() def on_pie_chart_change(self): self._redraw() def clear_selection(self): self.selection = None self.redraw_selection() def on_selection_change(self, selection, action=SomView.SelectionSet): if self.data is None: # clicks on empty canvas return if self.selection is None: self.selection = np.zeros(self.grid_cells.T.shape, dtype=np.int16) if action == SomView.SelectionSet: self.selection[:] = 0 self.selection[selection] = 1 elif action == SomView.SelectionAddToGroup: self.selection[selection] = max(1, np.max(self.selection)) elif action == SomView.SelectionNewGroup: self.selection[selection] = 1 + np.max(self.selection) elif action & SomView.SelectionRemove: self.selection[selection] = 0 self.redraw_selection() self.update_output() def on_selection_move(self, event: QKeyEvent): if self.selection is None or not np.any(self.selection): if event.key() in (Qt.Key_Right, Qt.Key_Down): x = y = 0 else: x = self.size_x - 1 y = self.size_y - 1 else: x, y = np.nonzero(self.selection) if len(x) > 1: return if event.key() == Qt.Key_Up and y > 0: y -= 1 if event.key() == Qt.Key_Down and y < self.size_y - 1: y += 1 if event.key() == Qt.Key_Left and x: x -= 1 if event.key() == Qt.Key_Right and x < self.size_x - 1: x += 1 x -= self.hexagonal and x == self.size_x - 1 and y % 2 if self.selection is not None and self.selection[x, y]: return selection = np.zeros(self.grid_cells.shape, dtype=bool) selection[x, y] = True self.on_selection_change(selection) def on_selection_mark_change(self, marks): self.redraw_selection(marks=marks) def redraw_selection(self, marks=None): if self.grid_cells is None: return sel_pen = QPen(QBrush(QColor(128, 128, 128)), 2) sel_pen.setCosmetic(True) mark_pen = QPen(QBrush(QColor(128, 128, 128)), 4) mark_pen.setCosmetic(True) pens = [self._grid_pen, sel_pen] mark_brush = QBrush(QColor(224, 255, 255)) sels = self.selection is not None and np.max(self.selection) palette = LimitedDiscretePalette(number_of_colors=sels + 1) brushes = [QBrush(Qt.NoBrush)] + \ [QBrush(palette[i].lighter(165)) for i in range(sels)] for y in range(self.size_y): for x in range(self.size_x - (y % 2) * self.hexagonal): cell = self.grid_cells[y, x] marked = marks is not None and marks[x, y] sel_group = self.selection is not None and self.selection[x, y] if marked: cell.setBrush(mark_brush) cell.setPen(mark_pen) else: cell.setBrush(brushes[sel_group]) cell.setPen(pens[bool(sel_group)]) cell.setZValue(marked or sel_group) def restart_som_pressed(self): if self._optimizer_thread is not None: self.stop_optimization = True self._optimizer.stop_optimization = True else: self.start_som() def start_som(self): self.read_controls() self.update_layout() self.clear_selection() if self.cont_x is not None: self.enable_controls(False) self._recompute_som() else: self.update_output() def read_controls(self): c = self.opt_controls self.hexagonal = c.shape.currentIndex() == 0 self.size_x = c.spin_x.value() self.size_y = c.spin_y.value() def enable_controls(self, enable): c = self.opt_controls c.shape.setEnabled(enable) c.auto_dim.setEnabled(enable) c.start.setText("Start" if enable else "Stop") def update_layout(self): self.set_legend_pos() if self.elements: # Prevent having redrawn grid but with old elements self.scene.removeItem(self.elements) self.elements = None self.redraw_grid() self.rescale() def _redraw(self): self.Warning.missing_colors.clear() if self.elements: self.scene.removeItem(self.elements) self.elements = None self.view.set_dimensions(self.size_x, self.size_y, self.hexagonal) if self.cells is None: return sizes = self.cells[:, :, 1] - self.cells[:, :, 0] sizes = sizes.astype(float) if not self.size_by_instances: sizes[sizes != 0] = 0.8 else: sizes *= 0.8 / np.max(sizes) self.elements = QGraphicsItemGroup() self.scene.addItem(self.elements) if self.attr_color is None: self._draw_same_color(sizes) elif self.pie_charts: self._draw_pie_charts(sizes) else: self._draw_colored_circles(sizes) @property def _grid_factors(self): return (0.5, sqrt3_2) if self.hexagonal else (0, 1) def _draw_same_color(self, sizes): fx, fy = self._grid_factors color = QColor(64, 64, 64) for y in range(self.size_y): for x in range(self.size_x - self.hexagonal * (y % 2)): r = sizes[x, y] n = len(self.get_member_indices(x, y)) if not r: continue ellipse = ColoredCircle(r / 2, color, 0) ellipse.setPos(x + (y % 2) * fx, y * fy) ellipse.setToolTip(f"{n} instances") self.elements.addToGroup(ellipse) def _get_color_column(self): color_column = \ self.data.get_column_view(self.attr_color)[0].astype(float, copy=False) if self.attr_color.is_discrete: with np.errstate(invalid="ignore"): int_col = color_column.astype(int) int_col[np.isnan(color_column)] = len(self.colors) else: int_col = np.zeros(len(color_column), dtype=int) # The following line is unnecessary because rows with missing # numeric data are excluded. Uncomment it if you change SOM to # tolerate missing values. # int_col[np.isnan(color_column)] = len(self.colors) for i, thresh in enumerate(self.thresholds, start=1): int_col[color_column >= thresh] = i return int_col def _tooltip(self, colors, distribution): if self.attr_color.is_discrete: values = self.attr_color.values else: values = self._bin_names() tot = np.sum(distribution) nbhp = "\N{NON-BREAKING HYPHEN}" return '<table style="white-space: nowrap">' + "".join(f""" <tr> <td> <font color={color.name()}>■</font> <b>{escape(val).replace("-", nbhp)}</b>: </td> <td> {n} ({n / tot * 100:.1f} %) </td> </tr> """ for color, val, n in zip(colors, values, distribution) if n) \ + "</table>" def _draw_pie_charts(self, sizes): fx, fy = self._grid_factors color_column = self._get_color_column() colors = self.colors.qcolors_w_nan for y in range(self.size_y): for x in range(self.size_x - self.hexagonal * (y % 2)): r = sizes[x, y] if not r: self.grid_cells[y, x].setToolTip("") continue members = self.get_member_indices(x, y) color_dist = np.bincount(color_column[members], minlength=len(colors)) rel_color_dist = color_dist.astype(float) / len(members) pie = PieChart(rel_color_dist, r / 2, colors) pie.setToolTip(self._tooltip(colors, color_dist)) self.elements.addToGroup(pie) pie.setPos(x + (y % 2) * fx, y * fy) def _draw_colored_circles(self, sizes): fx, fy = self._grid_factors color_column = self._get_color_column() qcolors = self.colors.qcolors_w_nan for y in range(self.size_y): for x in range(self.size_x - self.hexagonal * (y % 2)): r = sizes[x, y] if not r: continue members = self.get_member_indices(x, y) color_dist = color_column[members] color_dist = color_dist[color_dist < len(self.colors)] if len(color_dist) != len(members): self.Warning.missing_colors(self.attr_color.name) bc = np.bincount(color_dist, minlength=len(self.colors)) color = qcolors[np.argmax(bc)] ellipse = ColoredCircle(r / 2, color, np.max(bc) / len(members)) ellipse.setPos(x + (y % 2) * fx, y * fy) ellipse.setToolTip(self._tooltip(qcolors, bc)) self.elements.addToGroup(ellipse) def redraw_grid(self): if self.grid is not None: self.scene.removeItem(self.grid) self.grid = QGraphicsItemGroup() self.grid.setZValue(-200) self.grid_cells = np.full((self.size_y, self.size_x), None) for y in range(self.size_y): for x in range(self.size_x - (y % 2) * self.hexagonal): if self.hexagonal: cell = QGraphicsPathItem(_hexagon_path) cell.setPos(x + (y % 2) / 2, y * sqrt3_2) else: cell = QGraphicsRectItem(x - 0.5, y - 0.5, 1, 1) self.grid_cells[y, x] = cell cell.setPen(self._grid_pen) self.grid.addToGroup(cell) self.scene.addItem(self.grid) def get_member_indices(self, x, y): i, j = self.cells[x, y] return self.member_data[i:j] def _recompute_som(self): if self.cont_x is None: return som = SOM(self.size_x, self.size_y, hexagonal=self.hexagonal, pca_init=self.initialization == 0, random_seed=0 if self.initialization == 2 else None) class Optimizer(QObject): update = Signal(float, np.ndarray, np.ndarray) done = Signal(SOM) stopped = Signal() stop_optimization = False def __init__(self, data, som): super().__init__() self.som = som self.data = data def callback(self, progress): self.update.emit(progress, self.som.weights.copy(), self.som.ssum_weights.copy()) return not self.stop_optimization def run(self): try: self.som.fit(self.data, N_ITERATIONS, callback=self.callback) # Report an exception, but still remove the thread finally: self.done.emit(self.som) self.stopped.emit() def thread_finished(): self._optimizer = None self._optimizer_thread = None self.progressBarInit() self._optimizer = Optimizer(self.cont_x, som) self._optimizer_thread = QThread() self._optimizer_thread.setStackSize(5 * 2**20) self._optimizer.update.connect(self.__update) self._optimizer.done.connect(self.__done) self._optimizer.stopped.connect(self._optimizer_thread.quit) self._optimizer.moveToThread(self._optimizer_thread) self._optimizer_thread.started.connect(self._optimizer.run) self._optimizer_thread.finished.connect(thread_finished) self.stop_optimization = False self._optimizer_thread.start() @Slot(float, object, object) def __update(self, _progress, weights, ssum_weights): self.progressBarSet(_progress) self._assign_instances(weights, ssum_weights) self._redraw() @Slot(object) def __done(self, som): self.enable_controls(True) self.progressBarFinished() self._assign_instances(som.weights, som.ssum_weights) self._redraw() # This is the first time we know what was selected (assuming that # initialization is not set to random) if self.__pending_selection is not None: self.on_selection_change(self.__pending_selection) self.__pending_selection = None self.update_output() def stop_optimization_and_wait(self): if self._optimizer_thread is not None: self.stop_optimization = True self._optimizer.stop_optimization = True self._optimizer_thread.quit() self._optimizer_thread.wait() self._optimizer_thread = None def onDeleteWidget(self): self.stop_optimization_and_wait() self.clear() super().onDeleteWidget() def _assign_instances(self, weights, ssum_weights): if self.cont_x is None: return # the widget is shutting down while signals still processed assignments = SOM.winner_from_weights(self.cont_x, weights, ssum_weights, self.hexagonal) members = defaultdict(list) for i, (x, y) in enumerate(assignments): members[(x, y)].append(i) members.pop(None, None) self.cells = np.empty((self.size_x, self.size_y, 2), dtype=int) self.member_data = np.empty(self.cont_x.shape[0], dtype=int) index = 0 for x in range(self.size_x): for y in range(self.size_y): nmembers = len(members[(x, y)]) self.member_data[index:index + nmembers] = members[(x, y)] self.cells[x, y] = [index, index + nmembers] index += nmembers def resizeEvent(self, event): super().resizeEvent(event) self.create_legend() # re-wrap lines if necessary self.rescale() def rescale(self): if self.legend: leg_height = self.legend.boundingRect().height() leg_extra = 1.5 else: leg_height = 0 leg_extra = 1 vw, vh = self.view.width(), self.view.height() - leg_height scale = min(vw / (self.size_x + 1), vh / ((self.size_y + leg_extra) * self._grid_factors[1])) self.view.setTransform(QTransform.fromScale(scale, scale)) if self.hexagonal: self.view.setSceneRect(0, -1, self.size_x - 1, (self.size_y + leg_extra) * sqrt3_2 + leg_height / scale) else: self.view.setSceneRect(-0.25, -0.25, self.size_x - 0.5, self.size_y - 0.5 + leg_height / scale) def update_output(self): if self.data is None: self.Outputs.selected_data.send(None) self.Outputs.annotated_data.send(None) self._set_output_summary(None) return indices = np.zeros(len(self.data), dtype=int) if self.selection is not None and np.any(self.selection): for y in range(self.size_y): for x in range(self.size_x): rows = self.get_member_indices(x, y) indices[rows] = self.selection[x, y] if np.any(indices): sel_data = create_groups_table(self.data, indices, False, "Group") self.Outputs.selected_data.send(sel_data) self._set_output_summary(sel_data) else: self.Outputs.selected_data.send(None) self._set_output_summary(None) if np.max(indices) > 1: annotated = create_groups_table(self.data, indices) else: annotated = create_annotated_table(self.data, np.flatnonzero(indices)) self.Outputs.annotated_data.send(annotated) def set_color_bins(self): if self.attr_color is None: self.thresholds = self.bin_labels = self.colors = None elif self.attr_color.is_discrete: self.thresholds = self.bin_labels = None self.colors = self.attr_color.palette else: col = self.data.get_column_view(self.attr_color)[0].astype(float) if self.attr_color.is_time: binning = time_binnings(col, min_bins=4)[-1] else: binning = decimal_binnings(col, min_bins=4)[-1] self.thresholds = binning.thresholds[1:-1] self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1]) palette = BinnedContinuousPalette.from_palette( self.attr_color.palette, binning.thresholds) self.colors = palette def create_legend(self): if self.legend is not None: self.scene.removeItem(self.legend) self.legend = None if self.attr_color is None: return if self.attr_color.is_discrete: names = self.attr_color.values else: names = self._bin_names() items = [] size = 8 for name, color in zip(names, self.colors.qcolors): item = QGraphicsItemGroup() item.addToGroup( CanvasRectangle(None, -size / 2, -size / 2, size, size, Qt.gray, color)) item.addToGroup(CanvasText(None, name, size, 0, Qt.AlignVCenter)) items.append(item) self.legend = wrap_legend_items(items, hspacing=20, vspacing=16 + size, max_width=self.view.width() - 25) self.legend.setFlags(self.legend.ItemIgnoresTransformations) self.legend.setTransform( QTransform.fromTranslate(-self.legend.boundingRect().width() / 2, 0)) self.scene.addItem(self.legend) self.set_legend_pos() def _bin_names(self): labels, short_labels = self.bin_labels return \ [f"< {labels[0]}"] \ + [f"{x} - {y}" for x, y in zip(labels, short_labels[1:])] \ + [f"≥ {labels[-1]}"] def set_legend_pos(self): if self.legend is None: return self.legend.setPos(self.size_x / 2, (self.size_y + 0.2 + 0.3 * self.hexagonal) * self._grid_factors[1]) def send_report(self): self.report_plot() if self.attr_color: self.report_caption( f"Self-organizing map colored by '{self.attr_color.name}'")
class Legend(QGraphicsWidget): BAR_WIDTH = 7 BAR_HEIGHT = 150 FONT_SIZE = 11 def __init__(self, parent): super().__init__(parent) self.__offset = 2 self.__group = QGraphicsItemGroup(self) self._add_bar() self._add_high_label() self._add_low_label() self._add_feature_label() font = self.font() font.setPointSize(self.FONT_SIZE) self.set_font(font) def _add_bar(self): self._bar_item = QGraphicsRectItem() self._bar_item.setPen(QPen(Qt.NoPen)) self._set_bar(self.BAR_HEIGHT) self.__group.addToGroup(self._bar_item) def _add_high_label(self): self.__high_label = item = QGraphicsSimpleTextItem("High") item.setX(self.BAR_WIDTH + self.__offset) item.setY(0) self.__group.addToGroup(item) def _add_low_label(self): self.__low_label = item = QGraphicsSimpleTextItem("Low") item.setX(self.BAR_WIDTH + self.__offset) self.__group.addToGroup(item) def _add_feature_label(self): self.__feature_label = item = QGraphicsSimpleTextItem("Feature value") item.setRotation(-90) item.setX(self.BAR_WIDTH + self.__offset * 2) self.__group.addToGroup(item) def _set_font(self): for label in (self.__high_label, self.__low_label, self.__feature_label): label.setFont(self.font()) bar_height = self._bar_item.boundingRect().height() height = self.__low_label.boundingRect().height() self.__low_label.setY(bar_height - height) width = self.__feature_label.boundingRect().width() self.__feature_label.setY(bar_height / 2 + width / 2) def set_font(self, font: QFont): self.setFont(font) self._set_font() def _set_bar(self, height: int): self._bar_item.setRect(0, 0, self.BAR_WIDTH, height) gradient = QLinearGradient(0, 0, 0, height) gradient.setColorAt(0, QColor(*RGB_HIGH)) gradient.setColorAt(1, QColor(*RGB_LOW)) self._bar_item.setBrush(gradient) def set_bar(self, height: int): self._set_bar(height) self._set_font() def sizeHint(self, *_): width = self.__high_label.boundingRect().width() width += self._bar_item.boundingRect().width() + self.__offset return QSizeF(width, ViolinItem.HEIGHT)