diff --git a/orangecontrib/prototypes/dendrogram_nj.py b/orangecontrib/prototypes/dendrogram_nj.py new file mode 100644 index 00000000..a27abff2 --- /dev/null +++ b/orangecontrib/prototypes/dendrogram_nj.py @@ -0,0 +1,1307 @@ +import fractions +from collections import namedtuple, OrderedDict + +from typing import Tuple, List, Optional, Dict + +import numpy as np + +from AnyQt.QtCore import QPointF, QRectF, Qt, QSizeF, QEvent, Signal +from AnyQt.QtGui import ( + QPainterPath, QPen, QBrush, QPalette, QPainterPathStroker, QColor, + QTransform, QFontMetrics, QPolygonF +) +from AnyQt.QtWidgets import ( + QGraphicsWidget, QGraphicsPathItem, QGraphicsItemGroup, + QGraphicsSimpleTextItem +) + +from Orange.clustering.hierarchical import Tree, postorder, preorder, leaves +from Orange.widgets.utils import colorpalettes + +__all__ = [ + "DendrogramWidget" +] + + +def dendrogram_layout(tree, expand_leaves=False): + # type: (Tree, bool) -> List[Tuple[Tree, Tuple[float, float, float]]] + coords = [] + cluster_geometry = {} + leaf_idx = 0 + for node in postorder(tree): + cluster = node.value + if node.is_leaf: + if expand_leaves: + start = float(cluster.first) + 0.5 + end = float(cluster.last - 1) + 0.5 + else: + start = end = leaf_idx + 0.5 + leaf_idx += 1 + center = (start + end) / 2.0 + cluster_geometry[node] = (start, center, end) + coords.append((node, (start, center, end))) + else: + left = node.left + right = node.right + left_center = cluster_geometry[left][1] + right_center = cluster_geometry[right][1] + start, end = left_center, right_center + center = (start + end) / 2.0 + cluster_geometry[node] = (start, center, end) + coords.append((node, (start, center, end))) + + return coords + + +Point = namedtuple("Point", ["x", "y"]) +Element = namedtuple("Element", ["anchor", "path"]) + + +def path_toQtPath(geom): + p = QPainterPath() + anchor, points = geom + if len(points) > 1: + p.moveTo(*points[0]) + for (x, y) in points[1:]: + p.lineTo(x, y) + elif len(points) == 1: + r = QRectF(0, 0, 1e-0, 1e-9) + r.moveCenter(*points[0]) + p.addRect(r) + elif len(points) == 0: + r = QRectF(0, 0, 1e-16, 1e-16) + r.moveCenter(QPointF(*anchor)) + p.addRect(r) + return p + + +#: Dendrogram orientation flags +Left, Top, Right, Bottom = 1, 2, 3, 4 + + +def dendrogram_path( + tree, + orientation=Left, + scaleh=1, + *, + leaf_heights: bool = False, + display_height: Optional[float] = None +): + layout = dendrogram_layout(tree) + T = {} + paths = {} + rootdata = tree.value + display_height = rootdata.height if display_height is None else display_height + base = scaleh * display_height + + if orientation == Bottom: + transform = lambda x, y: (x, y) + if orientation == Top: + transform = lambda x, y: (x, base - y) + elif orientation == Left: + transform = lambda x, y: (base - y, x) + elif orientation == Right: + transform = lambda x, y: (y, x) + + def _height(node: Tree, *, leaf: bool = False) -> float: + if leaf and not leaf_heights: + height = 0.0 + else: + height = float(getattr(node.value, "height", 0.0)) + return min(max(height, 0.0), display_height) + + for node, (start, center, end) in layout: + if node.is_leaf: + # Orange's hierarchical trees are ultrametric, so leaves sit at + # height 0. Neighbor-joining trees can have non-zero leaf heights. + lh = scaleh * _height(node, leaf=True) + x, y = transform(center, lh) + anchor = Point(x, y) + paths[node] = Element(anchor, ()) + else: + left, right = paths[node.left], paths[node.right] + height = scaleh * _height(node) + lines = (left.anchor, + Point(*transform(start, height)), + Point(*transform(end, height)), + right.anchor) + anchor = Point(*transform(center, height)) + paths[node] = Element(anchor, lines) + + T[node] = Tree((node, paths[node]), + tuple(T[ch] for ch in node.branches)) + return T[tree] + + +def make_pen(brush=Qt.black, width=1, style=Qt.SolidLine, + cap_style=Qt.SquareCap, join_style=Qt.BevelJoin, + cosmetic=False): + pen = QPen(brush) + pen.setWidth(width) + pen.setStyle(style) + pen.setCapStyle(cap_style) + pen.setJoinStyle(join_style) + pen.setCosmetic(cosmetic) + return pen + + +def update_pen(pen, brush=None, width=None, style=None, + cap_style=None, join_style=None, + cosmetic=None): + pen = QPen(pen) + if brush is not None: + pen.setBrush(QBrush(brush)) + if width is not None: + pen.setWidth(width) + if style is not None: + pen.setStyle(style) + if cap_style is not None: + pen.setCapStyle(cap_style) + if join_style is not None: + pen.setJoinStyle(join_style) + if cosmetic is not None: + pen.setCosmetic(cosmetic) + return pen + + +def path_stroke(path, width=1, join_style=Qt.RoundJoin): + stroke = QPainterPathStroker() + stroke.setWidth(width) + stroke.setJoinStyle(join_style) + stroke.setMiterLimit(1.0) + return stroke.createStroke(path) + + +def path_outline(path, width=1, join_style=Qt.RoundJoin): + stroke = path_stroke(path, width, join_style) + return stroke.united(path) + + +class DendrogramWidget(QGraphicsWidget): + """A Graphics Widget displaying a dendrogram.""" + + class _LeafLabelItem(QGraphicsItemGroup): + """A label item anchored at a leaf end. + + Draws a small colored rectangle (like TextListView's color strip) + followed by the label text. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._rect = QGraphicsPathItem(self) + self._rect.setPen(make_pen(width=2, cosmetic=True)) + self._rect.setBrush(QBrush(Qt.NoBrush)) + self.addToGroup(self._rect) + + self._text = QGraphicsSimpleTextItem(self) + self.addToGroup(self._text) + + self._anchor = QPointF(0, 0) + self._selected = True + self._color = None # type: Optional[QColor] + self._max_width = None + + def set_anchor(self, p: QPointF) -> None: + self._anchor = QPointF(p) + self._relayout() + + def set_text(self, text: str) -> None: + self._text.setText(text or "") + self._relayout() + + def set_max_width(self, width: Optional[int]) -> None: + self._max_width = width + self._relayout() + + def set_font(self, font) -> None: + self._text.setFont(font) + self._relayout() + + def set_text_brush(self, brush) -> None: + self._text.setBrush(brush) + + def set_bold(self, bold: bool) -> None: + f = self._text.font() + if f.bold() != bold: + f.setBold(bold) + self._text.setFont(f) + self._relayout() + + def set_color(self, color: Optional[QColor], selected: bool = True) -> None: + self._color = QColor(color) if color is not None else None + self._selected = bool(selected) + self._update_rect_style() + + def _update_rect_style(self) -> None: + if self._color is None: + self._rect.setVisible(False) + return + + self._rect.setVisible(True) + + pen_color = QColor(self._color) + pen_color.setAlpha(140) + + self._rect.setPen(make_pen(pen_color, width=2, cosmetic=True)) + + if self._selected: + fill = QColor(self._color).lighter(140) + self._rect.setBrush(fill) + else: + self._rect.setBrush(QBrush(Qt.NoBrush)) + + def _relayout(self) -> None: + fm = QFontMetrics(self._text.font()) + size = max(8, int(round(fm.height()))) + margin = int(round(size * 0.2)) + side = max(2, size - 2 * margin) + radius = 1 + gap = 4 + + rpath = QPainterPath() + rpath.addRoundedRect(QRectF(margin, margin, side, side), radius, radius) + self._rect.setPath(rpath) + + text_br = self._text.boundingRect() + baseline_y = self._anchor.y() - text_br.height() / 2.0 + + self._rect.setPos(self._anchor.x(), self._anchor.y() - size / 2.0) + self._text.setPos(self._anchor.x() + size + gap, baseline_y) + + class ClusterGraphicsItem(QGraphicsPathItem): + #: The untransformed source path in 'dendrogram' logical coordinate + #: system + sourcePath = QPainterPath() # type: QPainterPath + sourceAreaShape = QPainterPath() # type: QPainterPath + + __shape = None # type: Optional[QPainterPath] + __boundingRect = None # type: Optional[QRectF] + #: An extended path describing the full mouse hit area + #: (extends all the way to the base of the dendrogram) + __mouseAreaShape = QPainterPath() # type: QPainterPath + + def setGeometryData(self, path, hitArea): + # type: (QPainterPath, QPainterPath) -> None + """ + Set the geometry (path) and the mouse hit area (hitArea) for this + item. + """ + super().setPath(path) + self.prepareGeometryChange() + self.__boundingRect = self.__shape = None + self.__mouseAreaShape = hitArea + + def shape(self): + # type: () -> QPainterPath + if self.__shape is None: + path = super().shape() # type: QPainterPath + self.__shape = path.united(self.__mouseAreaShape) + return self.__shape + + def boundingRect(self): + # type: () -> QRectF + if self.__boundingRect is None: + sh = self.shape() + pw = self.pen().widthF() / 2.0 + self.__boundingRect = sh.boundingRect().adjusted(-pw, -pw, pw, pw) + return self.__boundingRect + + class _SelectionItem(QGraphicsItemGroup): + def __init__(self, parent, path, unscaled_path, label=""): + super().__init__(parent) + self.path = QGraphicsPathItem(path, self) + self.path.setPen(make_pen(width=1, cosmetic=True)) + self.addToGroup(self.path) + + self.label = QGraphicsSimpleTextItem(label) + self._update_label_pos() + self.addToGroup(self.label) + + self.unscaled_path = unscaled_path + + def set_path(self, path): + self.path.setPath(path) + self._update_label_pos() + + def set_label(self, label): + self.label.setText(label) + self._update_label_pos() + + def set_color(self, color): + self.path.setBrush(QColor(color)) + + def _update_label_pos(self): + path = self.path.path() + elements = (path.elementAt(i) for i in range(path.elementCount())) + points = ((p.x, p.y) for p in elements) + p1, p2, *rest = sorted(points) + x, y = p1[0], (p1[1] + p2[1]) / 2 + brect = self.label.boundingRect() + # Leaf nodes' paths are 4 pixels higher; leaves are `len(rest) == 3`. + self.label.setPos(x - brect.width() - 4, + y - brect.height() + 4 * (len(rest) == 3)) + + #: Orientation + Left, Top, Right, Bottom = 1, 2, 3, 4 + + #: Selection flags + NoSelection, SingleSelection, ExtendedSelection = 0, 1, 2 + + #: Emitted when a user clicks on the cluster item. + itemClicked = Signal(ClusterGraphicsItem) + #: Signal emitted when the selection changes. + selectionChanged = Signal() + #: Signal emitted when the selection was changed by the user. + selectionEdited = Signal() + + def __init__(self, parent=None, root=None, orientation=Left, + hoverHighlightEnabled=True, selectionMode=ExtendedSelection, + *, pen_width=1, leaf_heights: bool = False, + **kwargs): + super().__init__(None, **kwargs) + # Filter all events from children (`ClusterGraphicsItem`s) + self.setFiltersChildEvents(True) + self.orientation = orientation + self._root = None + #: A tree with dendrogram geometry + self._layout = None + self._highlighted_item = None + #: a list of selected items + self._selection = OrderedDict() + #: a {node: item} mapping + self._items = {} # type: Dict[Tree, DendrogramWidget.ClusterGraphicsItem] + #: container for all cluster items. + self._itemgroup = QGraphicsWidget(self) + self._itemgroup.setGeometry(self.contentsRect()) + #: Transform mapping from 'dendrogram' to widget local coordinate + #: system + self._transform = QTransform() + self._cluster_parent = {} + self.__hoverHighlightEnabled = hoverHighlightEnabled + self.__selectionMode = selectionMode + self._pen_width = pen_width + self._leaf_heights = bool(leaf_heights) + # Leaf-end labels (text + optional color rectangle) + self._leaf_labels = [] + self._leaf_label_colors = None + self._leaf_label_selected = None + self._leaf_label_bold = None + self._leaf_label_items = [] + self._leaf_label_max_width = None + self._reference_height = None + self.setContentsMargins(0, 0, 0, 0) + self.setRoot(root) + if parent is not None: + self.setParentItem(parent) + + def set_reference_height(self, height: Optional[float]) -> None: + """Keep x-scale consistent by scaling against a fixed tree height.""" + self._reference_height = float(height) if height is not None else None + self._rescale() + + def set_leaf_labels( + self, + labels: List[str], + colors: Optional[List[Optional[QColor]]] = None, + selected: Optional[List[bool]] = None, + bold: Optional[List[bool]] = None, + ) -> None: + """Set leaf labels shown at the ends of leaf branches.""" + self._leaf_labels = list(labels or []) + self._leaf_label_colors = list(colors) if colors is not None else None + self._leaf_label_selected = list(selected) if selected is not None else None + self._leaf_label_bold = list(bold) if bold is not None else None + self._ensure_leaf_label_items() + self._update_leaf_label_items() + self._update_selection_items(recompute_outline=True) + + def clear_leaf_labels(self) -> None: + self.set_leaf_labels([]) + + def set_leaf_label_max_width(self, width: Optional[int]) -> None: + self._leaf_label_max_width = None if width is None else int(width) + for item in self._leaf_label_items: + item.set_max_width(self._leaf_label_max_width) + self._update_selection_items(recompute_outline=True) + + def leaf_label_width_hint(self) -> int: + """Return the width needed for the widest visible leaf-end label.""" + if not self._leaf_labels: + return 0 + + fm = QFontMetrics(self.font()) + size = max(8, int(round(fm.height()))) + gap = 4 + widths = [ + fm.horizontalAdvance(label or "") + for label in self._leaf_labels + ] + width = size + gap + max(widths, default=0) + if self._leaf_label_max_width is not None: + width = min(width, self._leaf_label_max_width) + return width + + def _ensure_leaf_label_items(self) -> None: + scene = self.scene() + while len(self._leaf_label_items) > len(self._leaf_labels): + it = self._leaf_label_items.pop() + it.setParentItem(None) + if scene is not None: + scene.removeItem(it) + while len(self._leaf_label_items) < len(self._leaf_labels): + # Keep labels as top-level scene items so they can extend into the + # reserved label column (outside this widget's geometry). + it = DendrogramWidget._LeafLabelItem(None) + it.set_max_width(self._leaf_label_max_width) + if scene is not None: + scene.addItem(it) + it.setZValue(1000) + self._leaf_label_items.append(it) + + def _update_leaf_label_items(self) -> None: + if not self._leaf_label_items: + return + if self._root is None: + for it in self._leaf_label_items: + it.hide() + return + + crect = self.contentsRect() + transform = getattr(self, "_transform", QTransform()) + leaf_nodes = list(leaves(self._root)) + if len(leaf_nodes) != len(self._leaf_labels): + for it in self._leaf_label_items: + it.hide() + return + + scene = self.scene() + text_brush = self.palette().brush(QPalette.Text) + font = self.font() + for i, (node, text) in enumerate(zip(leaf_nodes, self._leaf_labels)): + item = self._items.get(node) + ll = self._leaf_label_items[i] + if scene is not None and ll.scene() is None: + scene.addItem(ll) + if item is None: + ll.hide() + continue + + anchor = item.element.anchor + ap_local = transform.map(QPointF(anchor.x, anchor.y)) + crect.topLeft() + ap_scene = self.mapToScene(ap_local) + + ll.show() + ll.set_text_brush(text_brush) + ll.set_font(font) + ll.set_max_width(self._leaf_label_max_width) + ll.set_text(text) + ll.setPos(ap_scene) + ll.set_anchor(QPointF(0, 0)) + + if self._leaf_label_bold is not None and i < len(self._leaf_label_bold): + ll.set_bold(bool(self._leaf_label_bold[i])) + else: + ll.set_bold(False) + + if self._leaf_label_colors is not None and i < len(self._leaf_label_colors): + color = self._leaf_label_colors[i] + sel = True + if self._leaf_label_selected is not None and i < len(self._leaf_label_selected): + sel = bool(self._leaf_label_selected[i]) + ll.set_color(color, selected=sel) + else: + ll.set_color(None) + + def setSelectionMode(self, mode): + """ + Set the selection mode. + """ + assert mode in [DendrogramWidget.NoSelection, + DendrogramWidget.SingleSelection, + DendrogramWidget.ExtendedSelection] + + if self.__selectionMode != mode: + self.__selectionMode = mode + if self.__selectionMode == DendrogramWidget.NoSelection and \ + self._selection: + self.setSelectedClusters([]) + elif self.__selectionMode == DendrogramWidget.SingleSelection and \ + len(self._selection) > 1: + self.setSelectedClusters([self.selected_nodes()[-1]]) + + def selectionMode(self): + """ + Return the current selection mode. + """ + return self.__selectionMode + + def setHoverHighlightEnabled(self, enabled): + if self.__hoverHighlightEnabled != bool(enabled): + self.__hoverHighlightEnabled = bool(enabled) + if self._highlighted_item is not None: + self._set_hover_item(None) + + def isHoverHighlightEnabled(self): + return self.__hoverHighlightEnabled + + def clear(self): + """ + Clear the widget. + """ + scene = self.scene() + if scene is not None: + scene.removeItem(self._itemgroup) + else: + self._itemgroup.setParentItem(None) + self._itemgroup = QGraphicsWidget(self) + self._itemgroup.setGeometry(self.contentsRect()) + self._items.clear() + + for item in self._selection.values(): + if scene is not None: + scene.removeItem(item) + else: + item.setParentItem(None) + + self._root = None + self._items = {} + self._selection = OrderedDict() + self._highlighted_item = None + self._cluster_parent = {} + + # Leaf labels + scene = self.scene() + for it in getattr(self, "_leaf_label_items", []): + if scene is not None: + scene.removeItem(it) + else: + it.setParentItem(None) + self._leaf_label_items = [] + self._leaf_labels = [] + self._leaf_label_colors = None + self._leaf_label_selected = None + self._leaf_label_bold = None + self.updateGeometry() + + def setRoot(self, root): + # type: (Tree) -> None + """ + Set the root cluster tree node for display. + + Parameters + ---------- + root : Tree + The tree root node. + """ + self.clear() + self._root = root + if root is not None: + foreground = self.palette().color(QPalette.WindowText) + pen = make_pen(foreground, width=self._pen_width, cosmetic=True) + for node in postorder(root): + item = DendrogramWidget.ClusterGraphicsItem(self._itemgroup) + item.setAcceptHoverEvents(True) + item.setPen(pen) + item.node = node + for branch in node.branches: + assert branch in self._items + self._cluster_parent[branch] = node + self._items[node] = item + + self._relayout() + self._rescale() + self._update_leaf_label_items() + self.updateGeometry() + set_root = setRoot + + def root(self): + # type: () -> Tree + """ + Return the cluster tree root node. + + Returns + ------- + root : Tree + """ + return self._root + + def item(self, node): + # type: (Tree) -> DendrogramWidget.ClusterGraphicsItem + """ + Return the ClusterGraphicsItem instance representing the cluster `node`. + """ + return self._items.get(node) + + def heightAt(self, point): + # type: (QPointF) -> float + """ + Return the cluster height at the point in widget local coordinates. + """ + if not self._root: + return 0 + tinv, ok = self._transform.inverted() + if not ok: + return 0 + tpoint = tinv.map(point) + if self.orientation in [self.Left, self.Right]: + height = tpoint.x() + else: + height = tpoint.y() + # Undo geometry prescaling + base = self._display_height() + scale = self._height_scale_factor() + # Use better precision than double provides. + Fr = fractions.Fraction + if scale > 0: + height = Fr(height) / Fr(scale) + else: + height = 0 + if self.orientation in [self.Left, self.Bottom]: + height = Fr(base) - Fr(height) + return float(height) + height_at = heightAt + + def posAtHeight(self, height): + # type: (float) -> float + """ + Return a point in local coordinates for `height` (in cluster + """ + if not self._root: + return QPointF() + scale = self._height_scale_factor() + base = self._display_height() + height = scale * height + if self.orientation in [self.Left, self.Bottom]: + height = scale * base - height + + if self.orientation in [self.Left, self.Right]: + p = QPointF(height, 0) + else: + p = QPointF(0, height) + return self._transform.map(p) + pos_at_height = posAtHeight + + def _set_hover_item(self, item): + """Set the currently highlighted item.""" + if self._highlighted_item is item: + return + + def set_pen(item, pen): + def branches(item): + return [self._items[ch] for ch in item.node.branches] + for it in postorder(item, branches): + it.setPen(pen) + + if self._highlighted_item: + # Restore the previous item + highlight = self.palette().color(QPalette.WindowText) + set_pen(self._highlighted_item, + make_pen(highlight, width=self._pen_width, cosmetic=True)) + + self._highlighted_item = item + if item: + hpen = make_pen(self.palette().color(QPalette.Highlight), + width=self._pen_width + 1, cosmetic=True) + set_pen(item, hpen) + + def leafItems(self): + """Iterate over the dendrogram leaf items (:class:`QGraphicsItem`). + """ + if self._root: + return (self._items[leaf] for leaf in leaves(self._root)) + else: + return iter(()) + leaf_items = leafItems + + def leafAnchors(self): + """Iterate over the dendrogram leaf anchor points (:class:`QPointF`). + + The points are in the widget local coordinates. + """ + for item in self.leafItems(): + anchor = QPointF(item.element.anchor) + yield self.mapFromItem(item, anchor) + leaf_anchors = leafAnchors + + def selectedNodes(self): + """ + Return the selected cluster nodes. + """ + return [item.node for item in self._selection] + selected_nodes = selectedNodes + + def setSelectedItems(self, items: List[ClusterGraphicsItem]): + """Set the item selection.""" + to_remove = set(self._selection) - set(items) + to_add = set(items) - set(self._selection) + + for sel in to_remove: + self._remove_selection(sel) + for sel in to_add: + self._add_selection(sel) + + if to_add or to_remove: + self._re_enumerate_selections() + self.selectionChanged.emit() + set_selected_items = setSelectedItems + + def setSelectedClusters(self, clusters: List[Tree]) -> None: + """Set the selected clusters. + """ + self.setSelectedItems(list(map(self.item, clusters))) + set_selected_clusters = setSelectedClusters + + def isItemSelected(self, item: ClusterGraphicsItem) -> bool: + """Is `item` selected (is a root of a selection).""" + return item in self._selection + + def isItemIncludedInSelection(self, item: ClusterGraphicsItem) -> bool: + """Is item included in any selection.""" + return self._selected_super_item(item) is not None + is_included = isItemIncludedInSelection + + def setItemSelected(self, item, state): + # type: (ClusterGraphicsItem, bool) -> None + """Set the `item`s selection state to `state`.""" + if state is False and item not in self._selection or \ + state is True and item in self._selection: + return # State unchanged + + if item in self._selection: + if state is False: + self._remove_selection(item) + self._re_enumerate_selections() + self.selectionChanged.emit() + else: + # If item is already inside another selected item, + # remove that selection + super_selection = self._selected_super_item(item) + + if super_selection: + self._remove_selection(super_selection) + # Remove selections this selection will override. + sub_selections = self._selected_sub_items(item) + + for sub in sub_selections: + self._remove_selection(sub) + + if state: + self._add_selection(item) + + elif item in self._selection: + self._remove_selection(item) + + self._re_enumerate_selections() + self.selectionChanged.emit() + select_item = setItemSelected + + @staticmethod + def _create_path(item, path): + ppath = QPainterPath() + if item.node.is_leaf: + ppath.addRect(path.boundingRect().adjusted(-8, -4, 0, 4)) + else: + ppath.addPolygon(path) + ppath = path_outline(ppath, width=-8) + return ppath + + @staticmethod + def _create_label(i): + return f"C{i + 1}" + + def _add_selection(self, item): + """Add selection rooted at item + """ + outline = self._selection_poly(item) + path = self._transform.map(outline) + ppath = self._create_path(item, path) + label = self._create_label(len(self._selection)) + selection_item = self._SelectionItem(self, ppath, outline, label) + selection_item.label.setBrush(self.palette().color(QPalette.Link)) + selection_item.setPos(self.contentsRect().topLeft()) + self._selection[item] = selection_item + + def _remove_selection(self, item): + """Remove selection rooted at item.""" + + selection_item = self._selection[item] + + selection_item.hide() + selection_item.setParentItem(None) + if self.scene(): + self.scene().removeItem(selection_item) + + del self._selection[item] + + def _selected_sub_items(self, item): + """Return all selected subclusters under item.""" + def branches(item): + return [self._items[ch] for ch in item.node.branches] + + res = [] + for item in list(preorder(item, branches))[1:]: + if item in self._selection: + res.append(item) + return res + + def _selected_super_item(self, item): + """Return the selected super item if it exists.""" + def branches(item): + return [self._items[ch] for ch in item.node.branches] + + for selected_item in self._selection: + if item in set(preorder(selected_item, branches)): + return selected_item + return None + + def _re_enumerate_selections(self): + """Re enumerate the selection items and update the colors.""" + # Order the clusters + items = sorted(self._selection.items(), + key=lambda item: item[0].node.value.first) + + palette = colorpalettes.LimitedDiscretePalette(len(items)) + for i, (item, selection_item) in enumerate(items): + # delete and then reinsert to update the ordering + del self._selection[item] + self._selection[item] = selection_item + selection_item.set_label(self._create_label(i)) + color = palette[i] + color.setAlpha(150) + selection_item.set_color(color) + + def _selection_poly(self, item): + # type: (Tree) -> QPolygonF + """ + Return a selection geometry covering item and all its children. + """ + def left(item): + return [self._items[ch] for ch in item.node.branches[:1]] + + def right(item): + return [self._items[ch] for ch in item.node.branches[-1:]] + + itemsleft = list(preorder(item, left))[::-1] + itemsright = list(preorder(item, right)) + + assert itemsleft[0].node.is_leaf + assert itemsright[-1].node.is_leaf + + if item.node.is_leaf: + # a single anchor point + anchor = itemsleft[0].element.anchor + vert = [anchor] + if getattr(self, "_leaf_label_items", None) and self._leaf_label_items: + all_leaves = list(leaves(self._root)) if self._root else [] + leaf_to_idx = {leaf: i for i, leaf in enumerate(all_leaves)} + idx = leaf_to_idx.get(item.node) + + crect = self.contentsRect() + tinv, ok = self._transform.inverted() + if ok and idx is not None and idx < len(self._leaf_label_items): + ll = self._leaf_label_items[idx] + if ll is not None and ll.isVisible(): + sb = ll.mapToScene(ll.childrenBoundingRect()).boundingRect() + tl = self.mapFromScene(sb.topLeft()) + br = self.mapFromScene(sb.bottomRight()) + tl_d = tinv.map(tl - crect.topLeft()) + br_d = tinv.map(br - crect.topLeft()) + + x_right = max(anchor.x, br_d.x()) + y_top = tl_d.y() + y_bottom = br_d.y() + + if len(all_leaves) >= 2: + tops = [] + bottoms = [] + for leaf in all_leaves: + leaf_item = self._items.get(leaf) + ay = leaf_item.element.anchor.y \ + if leaf_item is not None else 0.0 + tops.append(ay) + bottoms.append(ay) + + dividers = [ + (bottoms[i] + tops[i + 1]) / 2.0 + for i in range(len(all_leaves) - 1) + ] + if idx > 0: + y_top = max(y_top, dividers[idx - 1]) + if idx < len(all_leaves) - 1: + y_bottom = min(y_bottom, dividers[idx]) + + vert = [ + Point(anchor.x, y_bottom), + Point(x_right, y_bottom), + Point(x_right, y_top), + Point(anchor.x, y_top), + Point(anchor.x, y_bottom), + ] + else: + vert = [] + for it in itemsleft[1:]: + vert.extend([ + it.element.path[0], + it.element.path[1], + it.element.anchor + ]) + for it in itemsright[:-1]: + vert.extend([ + it.element.anchor, + it.element.path[-2], + it.element.path[-1] + ]) + + # The original hierarchical dendrogram selection ends at leaf + # anchors. NJ labels are drawn as scene items outside the widget, + # so selected clusters need a label-aware outline. + top = vert[0] + bottom = vert[-1] + x_top_leaf = top.x + x_bottom_leaf = bottom.x + + leaf_nodes = list(leaves(item.node)) + leaf_items = [self._items[leaf] for leaf in leaf_nodes] + max_x_anchor = max(li.element.anchor.x for li in leaf_items) + + x_right = max_x_anchor + y_top_label = top.y + y_bottom_label = bottom.y + + if getattr(self, "_leaf_label_items", None) and self._leaf_label_items: + all_leaves = list(leaves(self._root)) if self._root else [] + leaf_to_idx = {leaf: i for i, leaf in enumerate(all_leaves)} + + crect = self.contentsRect() + tinv, ok = self._transform.inverted() + + if ok: + # Compute label bounds back in dendrogram coordinates so + # the same selection outline survives widget rescaling. + y_top_candidates = [] + y_bottom_candidates = [] + x_right_candidates = [] + + for leaf in leaf_nodes: + idx = leaf_to_idx.get(leaf) + if idx is None or idx >= len(self._leaf_label_items): + continue + + ll = self._leaf_label_items[idx] + if ll is None or not ll.isVisible(): + continue + + sb = ll.mapToScene(ll.childrenBoundingRect()).boundingRect() + tl = self.mapFromScene(sb.topLeft()) + br = self.mapFromScene(sb.bottomRight()) + + tl_d = tinv.map(tl - crect.topLeft()) + br_d = tinv.map(br - crect.topLeft()) + + y_top_candidates.append(tl_d.y()) + y_bottom_candidates.append(br_d.y()) + x_right_candidates.append(br_d.x()) + + if y_top_candidates and y_bottom_candidates and x_right_candidates: + y_top_label = min(y_top_candidates) + y_bottom_label = max(y_bottom_candidates) + x_right = max(max_x_anchor, max(x_right_candidates)) + + # Adjacent labels can be taller than the leaf spacing; + # split the available vertical space between neighbors. + if all_leaves and len(all_leaves) >= 2: + n = len(all_leaves) + + tops = [None] * n + bottoms = [None] * n + + for i in range(min(n, len(self._leaf_label_items))): + ll = self._leaf_label_items[i] + if ll is None or not ll.isVisible(): + leaf_item = self._items.get(all_leaves[i]) + if leaf_item is not None: + ay = leaf_item.element.anchor.y + tops[i] = ay + bottoms[i] = ay + continue + + sb = ll.mapToScene(ll.childrenBoundingRect()).boundingRect() + tl = self.mapFromScene(sb.topLeft()) + br = self.mapFromScene(sb.bottomRight()) + tl_d = tinv.map(tl - crect.topLeft()) + br_d = tinv.map(br - crect.topLeft()) + tops[i] = tl_d.y() + bottoms[i] = br_d.y() + + for i in range(n): + if tops[i] is None or bottoms[i] is None: + leaf_item = self._items.get(all_leaves[i]) + ay = leaf_item.element.anchor.y if leaf_item is not None else 0.0 + tops[i] = ay + bottoms[i] = ay + + dividers = [(bottoms[i] + tops[i + 1]) / 2.0 for i in range(n - 1)] + + selected_indices = [ + leaf_to_idx[leaf] for leaf in leaf_nodes + if leaf in leaf_to_idx + ] + if not selected_indices: + selected_indices = [0] + + # In pruned trees, node.value.first/last still refer + # to original leaves. Label items, however, follow the + # displayed leaves, so clamp by displayed leaf index. + first = min(selected_indices) + last = max(selected_indices) + + if first > 0: + top_limit = dividers[first - 1] + else: + top_limit = tops[0] - (dividers[0] - tops[0]) + if last < n - 1: + bottom_limit = dividers[last] + else: + bottom_limit = bottoms[-1] + (bottoms[-1] - dividers[-1]) + + y_top_label = max(y_top_label, top_limit) + y_bottom_label = min(y_bottom_label, bottom_limit) + + # close the polygon + vert.extend([ + Point(x_bottom_leaf, y_bottom_label), + Point(x_right, y_bottom_label), + Point(x_right, y_top_label), + Point(x_top_leaf, y_top_label), + top + ]) + + def isclose(a, b, rel_tol=1e-6): + return abs(a - b) < rel_tol * max(abs(a), abs(b)) + + def isclose_p(p1, p2, rel_tol=1e-6): + return isclose(p1.x, p2.x, rel_tol) and isclose(p1.y, p2.y, rel_tol) + + acc = [vert[0]] + for v in vert[1:]: + if not isclose_p(v, acc[-1]): + acc.append(v) + vert = acc + + return QPolygonF([QPointF(*p) for p in vert]) + + def _update_selection_items(self, recompute_outline: bool = False): + """Update the shapes of selection items after a scale/font/label change.""" + transform = self._transform + for item, selection in self._selection.items(): + if recompute_outline: + selection.unscaled_path = self._selection_poly(item) + path = transform.map(selection.unscaled_path) + ppath = self._create_path(item, path) + selection.set_path(ppath) + + def _height_scale_factor(self): + if self._root is None: + return 1 + # Pruning changes the displayed root height. Keep the x-scale tied to + # the original tree so pruned and unpruned views do not visually jump. + base = self._reference_height \ + if self._reference_height is not None \ + else self._root.value.height + if base >= np.finfo(base).eps: + return 1 / base + else: + return 0 + + def _display_height(self): + if self._root is None: + return 0.0 + return self._reference_height \ + if self._reference_height is not None \ + else self._root.value.height + + def _relayout(self): + if self._root is None: + return + + scale = self._height_scale_factor() + base = scale * self._display_height() + self._layout = dendrogram_path( + self._root, + self.orientation, + scaleh=scale, + leaf_heights=self._leaf_heights, + display_height=self._display_height(), + ) + for node_geom in postorder(self._layout): + node, geom = node_geom.value + item = self._items[node] + item.element = geom + # the untransformed source path + item.sourcePath = path_toQtPath(geom) + r = item.sourcePath.boundingRect() + + if self.orientation == Left: + r.setRight(base) + elif self.orientation == Right: + r.setLeft(0) + elif self.orientation == Top: + r.setBottom(base) + else: + r.setTop(0) + + hitarea = QPainterPath() + hitarea.addRect(r) + item.sourceAreaShape = hitarea + item.setGeometryData(item.sourcePath, item.sourceAreaShape) + item.setZValue(-node.value.height) + + def _rescale(self): + if self._root is None: + return + + scale = self._height_scale_factor() + base = scale * self._display_height() + crect = self.contentsRect() + leaf_count = len(list(leaves(self._root))) + if self.orientation in [Left, Right]: + drect = QSizeF(base, leaf_count) + else: + drect = QSizeF(leaf_count, base) + + eps = np.finfo(np.float64).eps + + if abs(drect.width()) < eps: + sx = 1.0 + else: + sx = crect.width() / drect.width() + + if abs(drect.height()) < eps: + sy = 1.0 + else: + sy = crect.height() / drect.height() + + transform = QTransform().scale(sx, sy) + self._transform = transform + self._itemgroup.setPos(crect.topLeft()) + self._itemgroup.setGeometry(crect) + for node_geom in postorder(self._layout): + node, _ = node_geom.value + item = self._items[node] + item.setGeometryData( + transform.map(item.sourcePath), + transform.map(item.sourceAreaShape) + ) + self._selection_items = None + self._update_selection_items() + self._update_leaf_label_items() + + def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QSizeF: + # reimplemented + fm = QFontMetrics(self.font()) + spacing = fm.lineSpacing() + mleft, mtop, mright, mbottom = self.getContentsMargins() + + if self._root and which == Qt.PreferredSize: + nleaves = len([node for node in self._items.keys() + if not node.branches]) + base = max(10, min(spacing * 16, 250)) + if self.orientation in [self.Left, self.Right]: + return QSizeF(base, spacing * nleaves + mleft + mright) + else: + return QSizeF(spacing * nleaves + mtop + mbottom, base) + + elif which == Qt.MinimumSize: + return QSizeF(mleft + mright + 10, mtop + mbottom + 10) + else: + return QSizeF() + + def sceneEventFilter(self, obj, event): + if isinstance(obj, DendrogramWidget.ClusterGraphicsItem): + if event.type() == QEvent.GraphicsSceneHoverEnter and \ + self.__hoverHighlightEnabled: + self._set_hover_item(obj) + event.accept() + return True + elif event.type() == QEvent.GraphicsSceneMousePress and \ + event.button() == Qt.LeftButton: + + is_selected = self.isItemSelected(obj) + is_included = self.is_included(obj) + current_selection = list(self._selection) + + if self.__selectionMode == DendrogramWidget.SingleSelection: + if event.modifiers() & Qt.ControlModifier: + self.setSelectedItems( + [obj] if not is_selected else []) + elif event.modifiers() & Qt.AltModifier: + self.setSelectedItems([]) + elif event.modifiers() & Qt.ShiftModifier: + if not is_included: + self.setSelectedItems([obj]) + elif current_selection != [obj]: + self.setSelectedItems([obj]) + elif self.__selectionMode == DendrogramWidget.ExtendedSelection: + if event.modifiers() & Qt.ControlModifier: + self.setItemSelected(obj, not is_selected) + elif event.modifiers() & Qt.AltModifier: + self.setItemSelected(self._selected_super_item(obj), False) + elif event.modifiers() & Qt.ShiftModifier: + if not is_included: + self.setItemSelected(obj, True) + elif current_selection != [obj]: + self.setSelectedItems([obj]) + + if current_selection != self._selection: + self.selectionEdited.emit() + self.itemClicked.emit(obj) + event.accept() + return True + + if event.type() == QEvent.GraphicsSceneHoverLeave: + self._set_hover_item(None) + + return super().sceneEventFilter(obj, event) + + def changeEvent(self, event): + # reimplemented + super().changeEvent(event) + if event.type() == QEvent.FontChange: + self.updateGeometry() + self._update_leaf_label_items() + self._update_selection_items(recompute_outline=True) + elif event.type() == QEvent.PaletteChange: + self._update_colors() + self._update_leaf_label_items() + elif event.type() == QEvent.ContentsRectChange: + self._rescale() + + def resizeEvent(self, event): + # reimplemented + super().resizeEvent(event) + self._rescale() + + def mousePressEvent(self, event): + # reimplemented + super().mousePressEvent(event) + # A mouse press on an empty widget part + if event.modifiers() == Qt.NoModifier and self._selection: + self.set_selected_clusters([]) + + def _update_colors(self): + def set_color(item: DendrogramWidget.ClusterGraphicsItem, color: QColor): + def branches(item): + return [self._items[ch] for ch in item.node.branches] + for it in postorder(item, branches): + it.setPen(update_pen(it.pen(), brush=color)) + if self._root is not None: + foreground = self.palette().color(QPalette.WindowText) + item = self.item(self._root) + set_color(item, foreground) + highlight = self.palette().color(QPalette.Highlight) + if self._highlighted_item is not None: + set_color(self._highlighted_item, highlight) + accent = self.palette().color(QPalette.Link) + for item in self._selection.values(): + item.label.setBrush(accent) diff --git a/orangecontrib/prototypes/neighbor_joining.py b/orangecontrib/prototypes/neighbor_joining.py new file mode 100644 index 00000000..70fdf53f --- /dev/null +++ b/orangecontrib/prototypes/neighbor_joining.py @@ -0,0 +1,185 @@ +import numpy as np + +from typing import List, Optional + + +class TreeNode: + """ + Minimal tree node for phylogenetic trees. + + name : taxon or internal node name + length : branch length to parent (root usually 0) + children : list of child nodes (empty for leaves) + """ + + __slots__ = ("name", "length", "children", "_abs_height", "_dist_from_root") + + def __init__(self, name: Optional[str] = None, length: float = 0.0): + self.name: Optional[str] = name + self.length: float = float(length) + self.children: List["TreeNode"] = [] + + def is_leaf(self) -> bool: + return not self.children + + def add_child(self, child: "TreeNode"): + self.children.append(child) + + +def neighbor_joining_core(D: np.ndarray, labels: List[str]) -> TreeNode: + """ + Core implementation of Neighbor Joining. + + Parameters + ---------- + D : np.ndarray (n x n) + Symmetric distance matrix with zeros on the diagonal. + labels : list[str] + Taxon labels of length n. + + Returns + ------- + root : TreeNode + Root of the reconstructed (artificially rooted) tree. + + Notes + ----- + NJ is inherently unrooted. We root the final tree by splitting + the last remaining edge in half to obtain a binary rooted tree + suitable for Newick export and comparison. + """ + D = np.array(D, dtype=float) + n = D.shape[0] + if D.shape[1] != n: + raise ValueError("D must be a square matrix") + if len(labels) != n: + raise ValueError("len(labels) must match matrix size") + + nodes: List[TreeNode] = [ + TreeNode(name=str(labels[i]), length=0.0) for i in range(n) + ] + + next_internal_id = 1 + + while n > 2: + r = D.sum(axis=1) + + Q = (n - 2) * D - r[:, None] - r[None, :] + np.fill_diagonal(Q, np.inf) + + idx_min = np.argmin(Q) + u, v = divmod(idx_min, n) + if u > v: + u, v = v, u + + delta = (r[u] - r[v]) / (n - 2) + limb_u = 0.5 * (D[u, v] + delta) + limb_v = D[u, v] - limb_u + + m_name = f"Node{next_internal_id}" + next_internal_id += 1 + m = TreeNode(name=m_name, length=0.0) + + node_u = nodes[u] + node_v = nodes[v] + node_u.length = limb_u + node_v.length = limb_v + m.add_child(node_u) + m.add_child(node_v) + + idxs = [i for i in range(n) if i not in (u, v)] + d_u = D[u, idxs] + d_v = D[v, idxs] + d_uv = D[u, v] + d_m = 0.5 * (d_u + d_v - d_uv) + + n_new = n - 1 + D_new = np.zeros((n_new, n_new), dtype=float) + + D_reduced = D[np.ix_(idxs, idxs)] # (n-2, n-2) + D_new[:n_new - 1, :n_new - 1] = D_reduced + + last_idx = n_new - 1 + D_new[last_idx, last_idx] = 0.0 + D_new[last_idx, :last_idx] = d_m + D_new[:last_idx, last_idx] = d_m + + D = D_new + n = n_new + + new_nodes = [nodes[i] for i in idxs] + new_nodes.append(m) + nodes = new_nodes + + # Final two clusters: create root in the middle of the last edge + d_last = D[0, 1] + root = TreeNode(name="Root", length=0.0) + + nodes[0].length = d_last / 2.0 + nodes[1].length = d_last / 2.0 + root.add_child(nodes[0]) + root.add_child(nodes[1]) + + return root + + +def get_leaves(node: TreeNode) -> List[str]: + """ + Get the list of leaf names under a given node. + """ + if node.is_leaf(): + return [node.name] + leaves = [] + for child in node.children: + leaves.extend(get_leaves(child)) + return leaves + + +def reorder_children(node, D, label_to_index): + """ + Recursively reorder children of internal nodes. + """ + if node.is_leaf(): + return + + for child in node.children: + reorder_children(child, D, label_to_index) + + if len(node.children) == 2: + left, right = node.children + left_leaves = get_leaves(left) + right_leaves = get_leaves(right) + + d_current = D[ + label_to_index[left_leaves[-1]], label_to_index[right_leaves[0]] + ] + d_swapped = D[ + label_to_index[left_leaves[0]], label_to_index[right_leaves[0]] + ] + + if d_swapped < d_current: + node.children[0], node.children[1] = right, left + + +def to_newick(node: TreeNode) -> str: + """ + Convert a rooted TreeNode tree to a Newick string. + """ + + def _rec(n: TreeNode) -> str: + if n.is_leaf(): + name = n.name if n.name is not None else "" + return f"{name}:{n.length:.6f}" + else: + parts = [] + # deterministic order by child name if present + for ch in sorted(n.children, + key=lambda x: "" if x.name is None else x.name): + parts.append(_rec(ch)) + inner = ",".join(parts) + if n.name is None or n.name == "Root": + return f"({inner})" + else: + return f"({inner}){n.name}:{n.length:.6f}" + + return _rec(node) + ";" diff --git a/orangecontrib/prototypes/neighbor_joining_adapter.py b/orangecontrib/prototypes/neighbor_joining_adapter.py new file mode 100644 index 00000000..9bd24932 --- /dev/null +++ b/orangecontrib/prototypes/neighbor_joining_adapter.py @@ -0,0 +1,93 @@ +""" +Adapter: Neighbor Joining TreeNode -> Orange hierarchical.Tree + +This module converts a NJ tree expressed with TreeNode (branch lengths) +into Orange's hierarchical.Tree with absolute heights, suitable for +DendrogramWidget (including non-ultrametric trees). + +Intended usage: + nj_root = neighbor_joining_core(D, labels) + orange_tree = treenode_to_orange_tree(nj_root) +""" + +from dataclasses import dataclass +from typing import Tuple + +from Orange.clustering.hierarchical import Tree + + +@dataclass(frozen=True) +class ClusterValue: + height: float + first: int # leaf-order position (inclusive) + last: int # leaf-order position (exclusive) + index: int # original row index for leaves, -1 for internal nodes + range: Tuple[int, int] + members: Tuple[int, ...] # original row indices contained in this subtree + + +def _compute_root_distances(node, current=0.0): + node._dist_from_root = current + for ch in node.children: + _compute_root_distances(ch, current + ch.length) + + +def _max_root_distance(node) -> float: + if not node.children: + return node._dist_from_root + return max(_max_root_distance(ch) for ch in node.children) + + +def _assign_orange_heights(node, max_height): + node._abs_height = max_height - node._dist_from_root + for ch in node.children: + _assign_orange_heights(ch, max_height) + + +def treenode_to_orange_tree(root, label_to_index) -> Tree: + # Orange dendrograms use height above the leaf baseline. NJ trees are not + # ultrametric, so leaves can have positive heights after this conversion. + _compute_root_distances(root) + max_h = _max_root_distance(root) + _assign_orange_heights(root, max_h) + leaf_pos = 0 + + def _build(node) -> Tree: + nonlocal leaf_pos + if not node.children: # true leaf + orig_idx = label_to_index[node.name] + pos = leaf_pos + leaf_pos += 1 + + val = ClusterValue( + height=node._abs_height, + first=pos, + last=pos + 1, + index=orig_idx, + range=(pos, pos + 1), + members=(orig_idx,), + ) + return Tree(val, ()) + + children = tuple(_build(ch) for ch in node.children) + first = min(ch.value.first for ch in children) + last = max(ch.value.last for ch in children) + members = tuple(i for ch in children for i in ch.value.members) + + val = ClusterValue( + height=node._abs_height, + first=first, + last=last, + index=-1, + range=(first, last), + members=members, + ) + return Tree(val, children) + + return _build(root) + + +__all__ = [ + "ClusterValue", + "treenode_to_orange_tree" +] diff --git a/orangecontrib/prototypes/tests/test_neighbor_joining.py b/orangecontrib/prototypes/tests/test_neighbor_joining.py new file mode 100644 index 00000000..9a768f12 --- /dev/null +++ b/orangecontrib/prototypes/tests/test_neighbor_joining.py @@ -0,0 +1,264 @@ +import unittest +from itertools import chain + +import numpy as np + +from orangecontrib.prototypes.neighbor_joining import ( + TreeNode, + get_leaves, + neighbor_joining_core, + reorder_children, + to_newick, +) + +from orangecontrib.prototypes.neighbor_joining_adapter import ( + treenode_to_orange_tree, +) + +def flatten(seq): + return chain(*seq) + + +def lower_to_square(lower): + size = len(lower) + expected = size * (size - 1) // 2 + if len(list(flatten(lower))) != expected: + raise ValueError("lower diagonal must contain n * (n - 1) / 2 values") + + matrix = np.zeros((size, size), dtype=float) + for i, row in enumerate(lower): + if len(row) != i: + raise ValueError("lower diagonal rows must have lengths 0..n-1") + matrix[i, :i] = row + matrix[:i, i] = row + return matrix + + +def walk(node): + yield node + for child in node.children: + yield from walk(child) + + +def leaf_depths(root): + depths = {} + + def collect(node, depth): + if node.is_leaf(): + depths[node.name] = depth + for child in node.children: + collect(child, depth + child.length) + + collect(root, 0.0) + return depths + + +def pairwise_leaf_distances(root): + parent = {} + depth = {} + + def collect(node, distance): + depth[node] = distance + for child in node.children: + parent[child] = node + collect(child, distance + child.length) + + collect(root, 0.0) + nodes_by_name = { + node.name: node for node in depth + if node.is_leaf() + } + + def common_ancestor(left, right): + ancestors = set() + node = left + while node is not None: + ancestors.add(node) + node = parent.get(node) + node = right + while node not in ancestors: + node = parent.get(node) + return node + + distances = {} + labels = sorted(nodes_by_name) + for i, left_label in enumerate(labels): + for right_label in labels[i + 1:]: + left = nodes_by_name[left_label] + right = nodes_by_name[right_label] + ancestor = common_ancestor(left, right) + distances[left_label, right_label] = ( + depth[left] + depth[right] - 2 * depth[ancestor] + ) + return distances + + +def orange_preorder(tree): + yield tree + for branch in tree.branches: + yield from orange_preorder(branch) + + +def orange_leaves(tree): + if tree.is_leaf: + return [tree] + return [ + leaf + for branch in tree.branches + for leaf in orange_leaves(branch) + ] + + +class NeighborJoiningTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.four_labels = ["A", "B", "C", "D"] + cls.four_lower = [ + [], + [5], + [9, 10], + [9, 10, 8], + ] + cls.four_distances = lower_to_square(cls.four_lower) + + cls.clustered_labels = ["A", "B", "C", "D", "E", "F"] + cls.clustered_lower = [ + [], + [2], + [4, 4], + [4, 4, 2], + [7, 7, 7, 7], + [7, 7, 7, 7, 4], + ] + cls.clustered_distances = lower_to_square(cls.clustered_lower) + + cls.four_tree = neighbor_joining_core( + cls.four_distances, cls.four_labels) + cls.clustered_tree = neighbor_joining_core( + cls.clustered_distances, cls.clustered_labels) + + +class TestNeighborJoiningCore(NeighborJoiningTest): + def test_two_items_are_joined_by_split_edge(self): + distances = lower_to_square([[], [4]]) + + root = neighbor_joining_core(distances, ["A", "B"]) + + self.assertEqual(root.name, "Root") + self.assertEqual([child.name for child in root.children], ["A", "B"]) + self.assertEqual([child.length for child in root.children], [2, 2]) + self.assertEqual(to_newick(root), "(A:2.000000,B:2.000000);") + + def test_known_four_taxa_example(self): + self.assertEqual( + to_newick(self.four_tree), + "((A:2.000000,B:3.000000)Node1:1.500000," + "(C:4.000000,D:4.000000)Node2:1.500000);", + ) + self.assertEqual(pairwise_leaf_distances(self.four_tree), { + ("A", "B"): 5.0, + ("A", "C"): 9.0, + ("A", "D"): 9.0, + ("B", "C"): 10.0, + ("B", "D"): 10.0, + ("C", "D"): 8.0, + }) + + def test_all_labels_are_preserved(self): + self.assertCountEqual(get_leaves(self.clustered_tree), + self.clustered_labels) + self.assertEqual( + sum(node.is_leaf() for node in walk(self.clustered_tree)), + len(self.clustered_labels), + ) + + def test_topology_groups_nearest_neighbors(self): + clades = { + frozenset(get_leaves(node)) + for node in walk(self.clustered_tree) + if not node.is_leaf() + } + + self.assertIn(frozenset({"A", "B"}), clades) + self.assertIn(frozenset({"C", "D"}), clades) + self.assertIn(frozenset({"E", "F"}), clades) + + def test_reorder_children_prefers_closer_leaf_boundaries(self): + root = neighbor_joining_core( + self.clustered_distances, self.clustered_labels) + + reorder_children( + root, + self.clustered_distances, + dict(zip(self.clustered_labels, range(len(self.clustered_labels)))), + ) + + ordered_leaves = get_leaves(root) + self.assertEqual(set(ordered_leaves), set(self.clustered_labels)) + for left, right in (("A", "B"), ("C", "D"), ("E", "F")): + self.assertEqual( + abs(ordered_leaves.index(left) - ordered_leaves.index(right)), + 1, + ) + + def test_to_newick_sorts_children_for_deterministic_output(self): + root = TreeNode("Root") + root.add_child(TreeNode("B", 2)) + root.add_child(TreeNode("A", 1)) + + self.assertEqual(to_newick(root), "(A:1.000000,B:2.000000);") + + def test_invalid_input_raises(self): + with self.assertRaisesRegex(ValueError, "square"): + neighbor_joining_core(np.zeros((2, 3)), ["A", "B"]) + + with self.assertRaisesRegex(ValueError, "len"): + neighbor_joining_core(np.zeros((2, 2)), ["A"]) + + def test_deterministic(self): + first = to_newick(neighbor_joining_core( + self.clustered_distances, self.clustered_labels)) + second = to_newick(neighbor_joining_core( + self.clustered_distances, self.clustered_labels)) + + self.assertEqual(first, second) + + +@unittest.skipIf(treenode_to_orange_tree is None, "Orange is not installed") +class TestNeighborJoiningAdapter(NeighborJoiningTest): + def test_converts_to_orange_tree_with_leaf_metadata(self): + tree = treenode_to_orange_tree( + self.four_tree, + dict(zip(self.four_labels, range(len(self.four_labels)))), + ) + + leaf_values = [leaf.value for leaf in orange_leaves(tree)] + self.assertEqual(tree.value.first, 0) + self.assertEqual(tree.value.last, len(self.four_labels)) + self.assertEqual(tree.value.members, tuple(range(len(self.four_labels)))) + self.assertCountEqual([value.index for value in leaf_values], range(4)) + self.assertTrue(all(value.last == value.first + 1 + for value in leaf_values)) + self.assertTrue(all(node.value.height >= 0 + for node in orange_preorder(tree))) + + def test_orange_heights_match_distance_to_furthest_leaf(self): + depths = leaf_depths(self.four_tree) + max_depth = max(depths.values()) + + tree = treenode_to_orange_tree( + self.four_tree, + dict(zip(self.four_labels, range(len(self.four_labels)))), + ) + + self.assertAlmostEqual(tree.value.height, max_depth) + for leaf in orange_leaves(tree): + label = self.four_labels[leaf.value.index] + self.assertAlmostEqual( + leaf.value.height, + max_depth - depths[label], + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/orangecontrib/prototypes/widgets/owneighborjoining.py b/orangecontrib/prototypes/widgets/owneighborjoining.py new file mode 100644 index 00000000..0c16c9a2 --- /dev/null +++ b/orangecontrib/prototypes/widgets/owneighborjoining.py @@ -0,0 +1,1156 @@ +from itertools import chain +from contextlib import contextmanager + +from typing import List, Optional, Union +import heapq +import numpy as np + +from AnyQt.QtWidgets import ( + QGraphicsWidget, QGraphicsScene, QGridLayout, QSizePolicy, + QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent, QLabel +) +from AnyQt.QtGui import QPen, QFont, QKeySequence, QPainterPath, QFontMetrics +from AnyQt.QtCore import ( + Qt, QObject, QSize, QPointF, QRectF, QLineF, QEvent +) +from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot + +from Orange.widgets.utils.localization import pl +from orangewidget.utils.signals import LazyValue + +import Orange.data +from Orange.data.domain import filter_visible +from Orange.data import DiscreteVariable, ContinuousVariable, \ + StringVariable, Table +import Orange.misc +from Orange.clustering.hierarchical import preorder, leaves, prune +from Orange.data.util import get_unique_names + +from Orange.widgets import widget, gui, settings +from Orange.widgets.utils import itemmodels, combobox +from Orange.widgets.utils.annotated_data import (lazy_annotated_table, + ANNOTATED_DATA_SIGNAL_NAME, + domain_with_annotation_column, + add_columns, + create_annotated_table) +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils.plotutils import AxisItem +from Orange.widgets.widget import Input, Output, Msg + +from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView +from Orange.widgets.utils.graphicsview import GraphicsWidgetView + +from orangecontrib.prototypes.neighbor_joining import ( + neighbor_joining_core, reorder_children +) +from orangecontrib.prototypes.neighbor_joining_adapter import ( + treenode_to_orange_tree +) +from orangecontrib.prototypes.dendrogram_nj import DendrogramWidget + +__all__ = ["OWNeighborJoining"] + + +MAX_ITEMS = 1000 +MAX_PRUNED_LABEL_WIDTH = 200 + + +@contextmanager +def blocked(obj): + old = obj.signalsBlocked() + obj.blockSignals(True) + try: + yield obj + finally: + obj.blockSignals(old) + + +class GraphicsView(GraphicsWidgetView, StickyGraphicsView): + def minimumSizeHint(self) -> QSize: + msh = super().minimumSizeHint() + w = self.centralWidget() + if w is not None: + width = w.minimumWidth() + 4 + self.verticalScrollBar().width() + msh.setWidth(max(int(width), msh.width())) + return msh + + def eventFilter(self, recv: QObject, event: QEvent) -> bool: + ret = super().eventFilter(recv, event) + if event.type() == QEvent.LayoutRequest and recv is self.centralWidget(): + self.updateGeometry() + return ret + + +class OWNeighborJoining(widget.OWWidget): + name = "Neighbor Joining" + description = "Display a dendrogram of a neighbor joining " \ + "constructed from the input distance matrix." + icon = "icons/HierarchicalClustering.svg" + priority = 2100 + keywords = "neighbor joining" + + class Inputs: + distances = Input("Distances", Orange.misc.DistMatrix) + data = Input("Data", Orange.data.Table) + subset = Input("Data Subset", Orange.data.Table, explicit=True) + + class Outputs: + selected_data = Output("Selected Data", Orange.data.Table, default=True) + annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table) + + settings_version = 2 + settingsHandler = settings.DomainContextHandler() + + #: Index of the selected annotation item (variable, ...) + annotation = settings.ContextSetting("Enumeration") + #: Out-of-context setting for the case when the "Name" option is available + annotation_if_names = settings.Setting("Name") + #: Out-of-context setting for the case with just "Enumeration" and "None" + annotation_if_enumerate = settings.Setting("Enumeration") + #: Selected tree pruning (none/max depth) + pruning = settings.Setting(0) + #: Maximum depth when max depth pruning is selected + max_depth = settings.Setting(10) + + #: Selected cluster selection method (none, cut distance, top n) + selection_method = settings.Setting(0) + #: Cut height ratio wrt root height + cut_ratio = settings.Setting(75.0) + #: Number of top clusters to select + top_n = settings.Setting(3) + #: Dendrogram zoom factor + zoom_factor = settings.Setting(0) + #: Show labels only for subset (if present) + label_only_subset = settings.Setting(False) + #: Color for label decoration + color_by: Union[DiscreteVariable, ContinuousVariable, None] = \ + settings.ContextSetting(None) + + autocommit = settings.Setting(True) + + graph_name = "scene" # QGraphicsScene + + basic_annotations = [None, "Enumeration"] + + class Error(widget.OWWidget.Error): + empty_matrix = Msg("Distance matrix is empty.") + not_finite_distances = Msg("Some distances are infinite") + not_symmetric = widget.Msg("Distance matrix is not symmetric.") + no_numeric_features = Msg("No numeric features for distance computation.") + too_many_items = Msg( + "Neighbor Joining is too slow for this many items " + "({n}; maximum is {max_n})." + ) + distance_computation_error = Msg("Error computing distances: {error}") + tree_construction_error = Msg("Error constructing neighbor joining tree: {error}") + + class Warning(widget.OWWidget.Warning): + subset_on_no_table = \ + Msg("Unused data subset: distances do not refer to data instances") + subset_not_subset = \ + Msg("Some data from the subset does not appear in distance matrix") + subset_wrong = \ + Msg("Subset data refers to a different table") + pruning_disables_colors = \ + Msg("Pruned cluster doesn't show colors and indicate subset") + many_clusters = \ + Msg("Variables with too many values may " + "degrade the performance of downstream widgets.") + + def _set_valid_matrix(self, matrix): + self.matrix = None + if len(matrix) < 2: + self.Error.empty_matrix() + elif len(matrix) > MAX_ITEMS: + self.Error.too_many_items(n=len(matrix), max_n=MAX_ITEMS) + elif not matrix.is_symmetric(): + self.Error.not_symmetric() + elif not np.all(np.isfinite(matrix)): + self.Error.not_finite_distances() + else: + self.matrix = matrix + + def __init__(self): + super().__init__() + + self.matrix = None + self.data = None + self.items = None + self.subset = None + self.subset_rows = set() + self.root = None + self._displayed_root = None + self.cutoff_height = 0.0 + + spin_width = QFontMetrics(self.font()).horizontalAdvance("M" * 7) + + model = itemmodels.VariableListModel(placeholder="None") + model[:] = self.basic_annotations + + grid = QGridLayout() + gui.widgetBox(self.controlArea, "Annotations", orientation=grid) + self.label_cb = cb = combobox.ComboBoxSearch( + minimumContentsLength=14, + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon + ) + cb.setModel(model) + cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole)) + + def on_annotation_activated(): + self.annotation = self.label_cb.currentData(Qt.EditRole) + self._update_labels() + cb.activated.connect(on_annotation_activated) + + def on_annotation_changed(value): + self.label_cb.setCurrentIndex( + self.label_cb.findData(value, Qt.EditRole)) + self.connect_control("annotation", on_annotation_changed) + + grid.addWidget(self.label_cb, 0, 0, 1, 2) + + cb = gui.checkBox( + None, self, "label_only_subset", "Show labels only for subset", + disabled=True, + callback=self._update_labels, stateWhenDisabled=False) + grid.addWidget(cb, 1, 0, 1, 2) + + model = itemmodels.DomainModel( + valid_types=(DiscreteVariable, ContinuousVariable), + placeholder="None") + cb = gui.comboBox( + None, self, "color_by", orientation=Qt.Horizontal, + model=model, callback=self._update_labels, + sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, + QSizePolicy.Fixed), + contentsLength=10 + ) + self.color_by_label = QLabel("Color by:") + grid.addWidget(self.color_by_label, 2, 0) + grid.addWidget(cb, 2, 1) + + box = gui.radioButtons( + self.controlArea, self, "pruning", box="Pruning", + callback=self._invalidate_pruning) + grid = QGridLayout() + box.layout().addLayout(grid) + grid.addWidget( + gui.appendRadioButton(box, "None", addToLayout=False), + 0, 0 + ) + self.max_depth_spin = gui.spin( + box, self, "max_depth", minv=1, maxv=100, + controlWidth=spin_width, alignment=Qt.AlignRight, + callback=self._max_depth_changed, + keyboardTracking=False, addToLayout=False + ) + self.max_depth_spin.lineEdit().returnPressed.connect( + self._max_depth_return) + + grid.addWidget( + gui.appendRadioButton(box, "Max depth:", addToLayout=False), + 1, 0) + grid.addWidget(self.max_depth_spin, 1, 1) + + self.selection_box = gui.radioButtons( + self.controlArea, self, "selection_method", + box="Selection", + callback=self._selection_method_changed) + + grid = QGridLayout() + self.selection_box.layout().addLayout(grid) + grid.addWidget( + gui.appendRadioButton( + self.selection_box, "Manual", addToLayout=False), + 0, 0 + ) + grid.addWidget( + gui.appendRadioButton( + self.selection_box, "Height ratio:", addToLayout=False), + 1, 0 + ) + self.cut_ratio_spin = gui.spin( + self.selection_box, self, "cut_ratio", 0, 100, step=1e-1, + controlWidth=spin_width, alignment = Qt.AlignRight, + spinType=float, callback=self._cut_ratio_changed, + addToLayout=False + ) + self.cut_ratio_spin.setSuffix(" %") + self.cut_ratio_spin.lineEdit().returnPressed.connect( + self._cut_ratio_return) + + grid.addWidget(self.cut_ratio_spin, 1, 1) + + grid.addWidget( + gui.appendRadioButton( + self.selection_box, "Top N:", addToLayout=False), + 2, 0 + ) + self.top_n_spin = gui.spin( + self.selection_box, self, "top_n", 1, 1000, + controlWidth=spin_width, alignment=Qt.AlignRight, + callback=self._top_n_changed, addToLayout=False) + self.top_n_spin.lineEdit().returnPressed.connect(self._top_n_return) + grid.addWidget(self.top_n_spin, 2, 1) + + self.zoom_slider = gui.hSlider( + self.controlArea, self, "zoom_factor", box="Zoom", + minValue=-6, maxValue=3, step=1, ticks=True, createLabel=False, + callback=self.__update_font_scale) + + zoom_in = QAction( + "Zoom in", self, shortcut=QKeySequence.ZoomIn, + triggered=self.__zoom_in + ) + zoom_out = QAction( + "Zoom out", self, shortcut=QKeySequence.ZoomOut, + triggered=self.__zoom_out + ) + zoom_reset = QAction( + "Reset zoom", self, + shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0), + triggered=self.__zoom_reset + ) + self.addActions([zoom_in, zoom_out, zoom_reset]) + + self.controlArea.layout().addStretch() + + gui.auto_send(self.buttonsArea, self, "autocommit") + + self.scene = QGraphicsScene(self) + self.view = GraphicsView( + self.scene, + horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, + verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, + alignment=Qt.AlignLeft | Qt.AlignVCenter, + widgetResizable=True, + ) + # Disable conflicting action shortcuts. We define our own. + for a in self.view.viewActions(): + a.setEnabled(False) + self.mainArea.layout().setSpacing(1) + self.mainArea.layout().addWidget(self.view) + + def axis_view(orientation): + ax = AxisItem(orientation=orientation, maxTickLength=7) + ax.mousePressed.connect(self._activate_cut_line) + ax.mouseMoved.connect(self._activate_cut_line) + ax.mouseReleased.connect(self._activate_cut_line) + ax.setRange(1.0, 0.0) + return ax + + self.top_axis = axis_view("top") + self.bottom_axis = axis_view("bottom") + + self._main_graphics = QGraphicsWidget( + sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + ) + scenelayout = QGraphicsGridLayout() + scenelayout.setHorizontalSpacing(10) + scenelayout.setVerticalSpacing(10) + + self._main_graphics.setLayout(scenelayout) + self.scene.addItem(self._main_graphics) + self.view.setCentralWidget(self._main_graphics) + + self.dendrogram = DendrogramWidget(pen_width=2, leaf_heights=True) + self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding, + QSizePolicy.MinimumExpanding) + self.dendrogram.selectionChanged.connect(self._invalidate_output) + self.dendrogram.selectionEdited.connect(self._selection_edited) + + # DendrogramWidget paints NJ labels at branch ends. This empty column + # only reserves space for labels that extend outside the dendrogram. + self.labels = QGraphicsWidget() + self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + self.labels.setMinimumWidth(0) + self.labels.setMaximumWidth(0) + + scenelayout.addItem(self.top_axis, 0, 0, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + scenelayout.addItem(self.dendrogram, 1, 0, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + scenelayout.addItem(self.labels, 1, 1, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + scenelayout.addItem(self.bottom_axis, 2, 0, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + self.top_axis.setZValue(self.dendrogram.zValue() + 10) + self.bottom_axis.setZValue(self.dendrogram.zValue() + 10) + self.cut_line = SliderLine(self.top_axis, + orientation=Qt.Horizontal) + self.cut_line.valueChanged.connect(self._dendrogram_slider_changed) + self.dendrogram.geometryChanged.connect(self._dendrogram_geom_changed) + self._set_cut_line_visible(self.selection_method == 1) + self.__update_font_scale() + + @Inputs.distances + def set_distances(self, matrix): + self.Error.clear() + self.data = None + self.matrix = None + if matrix is not None: + try: + row_items = getattr(matrix, "row_items", None) + if isinstance(row_items, Orange.data.Table) \ + and not self._has_input_features(row_items): + self.Error.no_numeric_features() + return + self._set_valid_matrix(matrix) + except Exception as e: + self.Error.distance_computation_error(error=str(e)) + + @Inputs.data + def set_data(self, data): + self.Error.clear() + self.data = data + self.matrix = None + if data is not None: + try: + from Orange import distance + if not self._has_input_features(data): + self.Error.no_numeric_features() + return + if data.domain.has_continuous_attributes(): + matrix = distance.Euclidean(data, axis=1, impute=True) + elif data.domain.has_discrete_attributes(): + matrix = distance.Hamming(data, axis=1, impute=True) + else: + self.Error.no_numeric_features() + return + self._set_valid_matrix(matrix) + except Exception as e: + self.Error.distance_computation_error(error=str(e)) + + @staticmethod + def _has_input_features(data): + return bool(data.domain.attributes) + + @Inputs.subset + def set_subset(self, subset): + self.subset = subset + self.controls.label_only_subset.setDisabled(subset is None) + + def handleNewSignals(self): + matrix = self.matrix + if matrix is not None: + self._set_items(matrix.row_items, matrix.axis) + else: + self._set_items(None) + self._update() + + self.Warning.clear() + rows = set() + if self.subset and self.matrix is not None and self.root is not None: + subsetids = set(self.subset.ids) + if not isinstance(self.items, Orange.data.Table) \ + or not self.matrix.axis: + self.Warning.subset_on_no_table() + elif (dataids := set(self.items.ids)) and not subsetids & dataids: + self.Warning.subset_wrong() + elif not subsetids <= dataids: + self.Warning.subset_not_subset() + else: + indices = [leaf.value.index for leaf in leaves(self.root)] + rows = { + row for row, rowid in enumerate(self.items.ids[indices]) + if rowid in subsetids + } + elif self.subset: + self.Warning.subset_on_no_table() + + self.subset_rows = rows + self._update_labels() + self.commit.now() + + def _set_items(self, items, axis=1): + self.closeContext() + self.items = items + model = self.label_cb.model() + color_model = self.controls.color_by.model() + color_model.set_domain(None) + self.color_by = None + if len(model) == 2 and model[0] is None: + if model[1] == "Name": + self.annotation_if_names = self.annotation + if model[1] == self.basic_annotations[1]: + self.annotation_if_enumerate = self.annotation + if isinstance(items, Orange.data.Table) and axis: + metas_class = tuple( + filter_visible(chain(items.domain.metas, + items.domain.class_vars))) + visible_attrs = tuple(filter_visible(items.domain.attributes)) + if not (metas_class or visible_attrs): + model[:] = self.basic_annotations + else: + model[:] = ( + (None, ) + + metas_class + + (model.Separator, ) * bool(metas_class and visible_attrs) + + visible_attrs) + for meta in items.domain.metas: + if isinstance(meta, StringVariable): + self.annotation = meta + break + else: + if items.domain.class_vars: + # No string metas: show class + self.annotation = items.domain.class_vars[0] + else: + # No string metas and no class: show the first option + # which is not None (in the worst case, Enumeration) + self.annotation = model[1] + + color_model.set_domain(items.domain) + if items.domain.class_vars: + self.color_by = items.domain.class_vars[0] + self.openContext(items.domain) + elif isinstance(items, Orange.data.Table) and not axis \ + or (isinstance(items, list) and \ + all(isinstance(var, Orange.data.Variable) + for var in items)): + model[:] = (None, "Name") + self.annotation = self.annotation_if_names + else: + model[:] = self.basic_annotations + self.annotation = self.annotation_if_enumerate + + no_colors = len(color_model) == 1 + self.controls.color_by.setDisabled(no_colors) + self.color_by_label.setDisabled(no_colors) + + def _clear_plot(self): + self.dendrogram.set_root(None) + + def _set_displayed_root(self, root): + self._clear_plot() + self._displayed_root = root + # Pruned NJ trees can have a smaller displayed height. Keep the axis + # scale tied to the full tree so pruning does not visually rescale it. + height = self.root.value.height if self.root else None + self.dendrogram.set_reference_height(height) + self.dendrogram.set_root(root) + self._update_labels() + + def _update(self): + self._clear_plot() + distances = self.matrix + if distances is not None: + try: + D = np.asarray(distances, dtype=float) + + # Use stable, unique internal labels. Display labels can be + # duplicated, while the adapter maps TreeNode names back to rows. + labels = [str(i) for i in range(D.shape[0])] + label_to_index = dict(zip(labels, range(len(labels)))) + + nj_root = neighbor_joining_core(D, labels) + reorder_children(nj_root, D, label_to_index) + tree = treenode_to_orange_tree(nj_root, label_to_index) + + self.root = tree + self.top_axis.setRange(tree.value.height, 0.0) + self.bottom_axis.setRange(tree.value.height, 0.0) + + if self.pruning: + self._set_displayed_root(prune(tree, level=self.max_depth)) + else: + self._set_displayed_root(tree) + except Exception as ex: + self.root = None + self._set_displayed_root(None) + self.Error.tree_construction_error(error=str(ex)) + else: + self.root = None + self._set_displayed_root(None) + + self._apply_selection() + + + def _update_labels(self): + if not hasattr(self, "labels"): + # This method can be called during widget initialization when + # creating check box for label_only_subset, if it's value is + # initially True. + # See https://github.com/biolab/orange-widget-base/pull/213; + # if it's merged, this check can be removed. + return + + self.Warning.pruning_disables_colors( + shown=self.pruning + and (self.subset_rows or self.color_by is not None)) + labels = [] + if self.root and self._displayed_root: + indices = [leaf.value.index for leaf in leaves(self.root)] + + if self.annotation is None: + if not self.pruning \ + and self.subset_rows and self.color_by is None: + # Empty strings let the dendrogram still show subset bolding. + labels = [""] * len(indices) + else: + labels = [] + elif self.annotation == "Enumeration": + labels = [str(i+1) for i in indices] + elif self.annotation == "Name": + row_items = self.matrix.row_items + labels = [] + for i in indices: + item = row_items[i] + if isinstance(item, Orange.data.Instance): + # Prefer class label if present + if item.domain.class_vars: + labels.append(str(item.get_class())) + else: + labels.append(str(item)) + elif hasattr(item, "name"): + labels.append(item.name) + else: + labels.append(str(item)) + + elif isinstance(self.annotation, Orange.data.Variable): + col_data = self.items.get_column(self.annotation) + labels = [self.annotation.str_val(val) for val in col_data] + labels = [labels[idx] for idx in indices] + else: + labels = [] + if not self.pruning and \ + labels and self.label_only_subset and self.subset_rows: + labels = [label if row in self.subset_rows else "" + for row, label in enumerate(labels)] + + if labels and self._displayed_root is not self.root: + index_to_label = { + leaf.value.index: labels[pos] + for pos, leaf in enumerate(leaves(self.root)) + } + + new_labels = [] + for node in leaves(self._displayed_root): + leaf_indices = node.value.members + new_labels.append( + ", ".join(index_to_label[i] for i in leaf_indices) + ) + + labels = new_labels + + # Compute per-leaf colors (for the small rectangle) and subset bold mask + colors = None + if not self.pruning and self.color_by is not None and labels: + col = self.items.get_column(self.color_by) + colors = list(self.color_by.palette.values_to_qcolors(col[indices])) + + subset = set() if self.pruning else (self.subset_rows or set()) + bold_mask = [i in subset for i in range(len(labels))] if labels else [] + + self.dendrogram.set_leaf_label_max_width( + MAX_PRUNED_LABEL_WIDTH if self.pruning else None + ) + + # labels are rendered at branch ends + # by DendrogramWidget so non-ultrametric leaves stay visually attached. + if labels: + selected_mask = bold_mask if subset else None + self.dendrogram.set_leaf_labels(labels, colors=colors, + selected=selected_mask, + bold=bold_mask) + else: + self.dendrogram.clear_leaf_labels() + + self._update_label_column_width() + + def _update_label_column_width(self): + width = int(self.dendrogram.leaf_label_width_hint()) + self.labels.setMinimumWidth(width) + self.labels.setPreferredWidth(width) + self.labels.setMaximumWidth(width) + self.labels.updateGeometry() + + def _set_selected_nodes(self, selection): + # type: (List[Tree]) -> None + """ + Set the nodes in `selection` to be the current selected nodes. + + The selection nodes must be subtrees of the current `_displayed_root`. + """ + self.dendrogram.selectionChanged.disconnect(self._invalidate_output) + try: + self.dendrogram.set_selected_clusters(selection) + finally: + self.dendrogram.selectionChanged.connect(self._invalidate_output) + + def _max_depth_return(self): + if self.pruning != 1: + self.pruning = 1 + self._invalidate_pruning() + + def _max_depth_changed(self): + self.pruning = 1 + self._invalidate_pruning() + + def _invalidate_output(self): + self.commit.deferred() + + def _invalidate_pruning(self): + if self.root: + selection = self.dendrogram.selected_nodes() + ranges = [node.value.range for node in selection] + if self.pruning: + self._set_displayed_root( + prune(self.root, level=self.max_depth)) + else: + self._set_displayed_root(self.root) + selected = [node for node in preorder(self._displayed_root) + if node.value.range in ranges] + + self.dendrogram.set_selected_clusters(selected) + + self._apply_selection() + + @gui.deferred + def commit(self): + items = getattr(self.matrix, "items", self.items) + self.Warning.many_clusters.clear() + if not items or self.root is None: + self.Outputs.selected_data.send(None) + self.Outputs.annotated_data.send(None) + return + + selection = self.dendrogram.selected_nodes() + selection = sorted(selection, key=lambda c: c.value.first) + + indices = [leaf.value.index for leaf in leaves(self.root)] + + maps = [indices[node.value.first:node.value.last] + for node in selection] + if len(maps) > 20: + self.Warning.many_clusters() + + selected_indices = list(chain(*maps)) + + if not selected_indices: + self.Outputs.selected_data.send(None) + annotated_data = lazy_annotated_table(items, []) \ + if self.selection_method == 0 and self.matrix.axis else None + self.Outputs.annotated_data.send(annotated_data) + return + + selected_data = annotated_data = None + + if isinstance(items, Orange.data.Table) and self.matrix.axis == 1: + # Select rows + data, domain = items, items.domain + + c = np.full(self.matrix.shape[0], len(maps)) + for i, indices in enumerate(maps): + c[indices] = i + + clust_name = get_unique_names(domain, "Cluster") + values = [f"C{i + 1}" for i in range(len(maps))] + + sel_clust_var = Orange.data.DiscreteVariable( + name=clust_name, values=values) + sel_domain = add_columns(domain, metas=(sel_clust_var,)) + selected_data = LazyValue[Table]( + lambda: items.add_column( + sel_clust_var, c, to_metas=True)[c != len(maps)], + domain=sel_domain, length=len(selected_indices)) + + ann_clust_var = Orange.data.DiscreteVariable( + name=clust_name, values=values + ["Other"] + ) + ann_domain = add_columns( + domain_with_annotation_column(data)[0], metas=(ann_clust_var, )) + annotated_data = LazyValue[Table]( + lambda: create_annotated_table( + data=items.add_column(ann_clust_var, c, to_metas=True), + selected_indices=selected_indices), + domain=ann_domain, length=len(items) + ) + + elif isinstance(items, Orange.data.Table) and self.matrix.axis == 0: + # Select columns + attrs = [] + unselected_indices = sorted(set(range(self.root.value.last)) - + set(selected_indices)) + for clust, indices in chain(enumerate(maps, start=1), + [(0, unselected_indices)]): + for i in indices: + attr = items.domain[i].copy() + attr.attributes["cluster"] = clust + attrs.append(attr) + all_domain = Orange.data.Domain( + # len(unselected_indices) can be 0 + attrs[:len(attrs) - len(unselected_indices)], + items.domain.class_vars, items.domain.metas) + + selected_data = LazyValue[Table]( + lambda: items.from_table(all_domain, items), + domain=all_domain, length=len(items)) + + sel_domain = Orange.data.Domain( + attrs, + items.domain.class_vars, items.domain.metas) + annotated_data = LazyValue[Table]( + lambda: items.from_table(sel_domain, items), + domain=sel_domain, length=len(items)) + + self.Outputs.selected_data.send(selected_data) + self.Outputs.annotated_data.send(annotated_data) + + @Slot(QPointF) + def _activate_cut_line(self, pos: QPointF): + """Activate cut line selection an set cut value to `pos.x()`.""" + self.selection_method = 1 + self.cut_line.setValue(pos.x()) + self._selection_method_changed() + + def onDeleteWidget(self): + super().onDeleteWidget() + self._clear_plot() + self.dendrogram.clear() + self.dendrogram.deleteLater() + + def _dendrogram_geom_changed(self): + pos = self.dendrogram.pos_at_height(self.cutoff_height) + dendro_geom = self.dendrogram.geometry() + self._set_slider_value(pos.x(), dendro_geom.width()) + + self.cut_line.setLength( + self.bottom_axis.geometry().bottom() + - self.top_axis.geometry().top() + ) + + geom = self._main_graphics.geometry() + assert geom.topLeft() == QPointF(0, 0) + + def adjustLeft(rect): + rect = QRectF(rect) + rect.setLeft(geom.left()) + return rect + margin = 3 + self.scene.setSceneRect(geom) + self.view.setSceneRect(geom) + + def headerFooterRect(axis): + rect = QRectF(axis.geometry()) + rect.setLeft(dendro_geom.left()) + rect.setWidth(dendro_geom.width()) + return rect + + self.view.setHeaderSceneRect( + adjustLeft(headerFooterRect(self.top_axis)).adjusted(0, 0, 0, margin) + ) + self.view.setFooterSceneRect( + adjustLeft(headerFooterRect(self.bottom_axis)).adjusted(0, -margin, 0, 0) + ) + + def _dendrogram_slider_changed(self, value): + p = QPointF(value, 0) + cl_height = self.dendrogram.height_at(p) + + self.set_cutoff_height(cl_height) + + def _set_slider_value(self, value, span): + with blocked(self.cut_line): + self.cut_line.setRange(0, span) + self.cut_line.setValue(value) + + def set_cutoff_height(self, height): + self.cutoff_height = height + if self.root and self.root.value.height: + self.cut_ratio = 100 * height / self.root.value.height + self.select_max_height(height) + + def _set_cut_line_visible(self, visible): + self.cut_line.setVisible(visible) + + def select_top_n(self, n): + root = self._displayed_root + if not root: + return + + # Orange's top_clusters is tailored to hierarchical clustering. Keep + # splitting the highest visible NJ cluster until n clusters are shown. + heap = [(-root.value.height, id(root), root)] + + while len(heap) < n: + popped = [] + splittable = None + while heap: + pr, tie, cl = heapq.heappop(heap) + if not cl.is_leaf: + splittable = (pr, tie, cl) + break + popped.append((pr, tie, cl)) + + for item in popped: + heapq.heappush(heap, item) + if splittable is None: + break + + _, _, cl = splittable + for child in cl.branches: + heapq.heappush(heap, (-child.value.height, id(child), child)) + + clusters = [cl for _, _, cl in heap] + self.dendrogram.set_selected_clusters(clusters) + + def select_max_height(self, height): + root = self._displayed_root + if root: + clusters = clusters_at_height(root, height) + self.dendrogram.set_selected_clusters(clusters) + + def _cut_ratio_changed(self): + self.selection_method = 1 + self._selection_method_changed() + + def _cut_ratio_return(self): + if self.selection_method != 1: + self.selection_method = 1 + self._selection_method_changed() + + def _top_n_changed(self): + self.selection_method = 2 + self._selection_method_changed() + + def _top_n_return(self): + if self.selection_method != 2: + self.selection_method = 2 + self._selection_method_changed() + + def _selection_method_changed(self): + self._set_cut_line_visible(self.selection_method == 1) + if self.root: + self._apply_selection() + + def _apply_selection(self): + if not self.root: + return + + if self.selection_method == 0: + pass + elif self.selection_method == 1: + height = self.cut_ratio * self.root.value.height / 100 + self.set_cutoff_height(height) + pos = self.dendrogram.pos_at_height(height) + self._set_slider_value(pos.x(), self.dendrogram.size().width()) + elif self.selection_method == 2: + self.select_top_n(self.top_n) + + def _selection_edited(self): + # Selection was edited by clicking on a cluster in the + # dendrogram view. + self.selection_method = 0 + self._selection_method_changed() + self._invalidate_output() + + def __zoom_in(self): + def clip(minval, maxval, val): + return min(max(val, minval), maxval) + self.zoom_factor = clip(self.zoom_slider.minimum(), + self.zoom_slider.maximum(), + self.zoom_factor + 1) + self.__update_font_scale() + + def __zoom_out(self): + def clip(minval, maxval, val): + return min(max(val, minval), maxval) + self.zoom_factor = clip(self.zoom_slider.minimum(), + self.zoom_slider.maximum(), + self.zoom_factor - 1) + self.__update_font_scale() + + def __zoom_reset(self): + self.zoom_factor = 0 + self.__update_font_scale() + + def __update_font_scale(self): + font = self.scene.font() + factor = (1.25 ** self.zoom_factor) + font = qfont_scaled(font, factor) + self._main_graphics.setFont(font) + self.dendrogram.setFont(font) + self._update_label_column_width() + + def send_report(self): + annot = self.label_cb.currentText() + if isinstance(self.annotation, str): + annot = annot.lower() + if self.selection_method == 0: + sel = "manual" + elif self.selection_method == 1: + sel = "at {:.1f} of height".format(self.cut_ratio) + else: + sel = f"top {self.top_n} {pl(self.top_n, 'cluster')}" + self.report_items(( + ("Method", "Neighbor Joining"), + ("Annotation", annot), + ("Pruning", + self.pruning != 0 and "{} levels".format(self.max_depth)), + ("Selection", sel), + )) + self.report_plot() + + @classmethod + def migrate_context(cls, context, version): + if version < 2: + if context.values["annotation"] == "None": + context.values["annotation"] = None + + +def qfont_scaled(font, factor): + scaled = QFont(font) + if font.pointSizeF() != -1: + scaled.setPointSizeF(font.pointSizeF() * factor) + elif font.pixelSize() != -1: + scaled.setPixelSize(int(font.pixelSize() * factor)) + return scaled + + +class AxisItem(AxisItem): + mousePressed = Signal(QPointF, Qt.MouseButton) + mouseMoved = Signal(QPointF, Qt.MouseButtons) + mouseReleased = Signal(QPointF, Qt.MouseButton) + + #: \reimp + def wheelEvent(self, event): + event.ignore() # ignore event to propagate to the view -> scroll + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self.mousePressed.emit(event.pos(), event.button()) + super().mousePressEvent(event) + event.accept() + + def mouseMoveEvent(self, event): + self.mouseMoved.emit(event.pos(), event.buttons()) + super().mouseMoveEvent(event) + event.accept() + + def mouseReleaseEvent(self, event): + self.mouseReleased.emit(event.pos(), event.button()) + super().mouseReleaseEvent(event) + event.accept() + + +class SliderLine(QGraphicsWidget): + """A movable slider line.""" + valueChanged = Signal(float) + + def __init__(self, parent=None, orientation=Qt.Vertical, value=0.0, + length=10.0, **kwargs): + self._orientation = orientation + self._value = value + self._length = length + self._min = 0.0 + self._max = 1.0 + self._line: Optional[QLineF] = QLineF() + self._pen: Optional[QPen] = None + super().__init__(parent, **kwargs) + self.setAcceptedMouseButtons(Qt.LeftButton) + if self._orientation == Qt.Vertical: + self.setCursor(Qt.SizeVerCursor) + else: + self.setCursor(Qt.SizeHorCursor) + + def pen(self) -> QPen: + if self._pen is None: + return QPen(self.palette().text(), 1.0, Qt.DashLine) + else: + return QPen(self._pen) + + def setValue(self, value: float): + value = min(max(value, self._min), self._max) + + if self._value != value: + self.prepareGeometryChange() + self._value = value + self._line = None + self.valueChanged.emit(value) + + def value(self) -> float: + return self._value + + def setRange(self, minval: float, maxval: float) -> None: + maxval = max(minval, maxval) + if minval != self._min or maxval != self._max: + self._min = minval + self._max = maxval + self.setValue(self._value) + + def setLength(self, length: float): + if self._length != length: + self.prepareGeometryChange() + self._length = length + self._line = None + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + pos = event.pos() + if self._orientation == Qt.Vertical: + self.setValue(pos.y()) + else: + self.setValue(pos.x()) + event.accept() + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self._orientation == Qt.Vertical: + self.setValue(event.pos().y()) + else: + self.setValue(event.pos().x()) + event.accept() + + def shape(self) -> QPainterPath: + path = QPainterPath() + path.addRect(self.boundingRect()) + return path + + def boundingRect(self) -> QRectF: + if self._line is None: + if self._orientation == Qt.Vertical: + self._line = QLineF(0, self._value, self._length, self._value) + else: + self._line = QLineF(self._value, 0, self._value, self._length) + r = QRectF(self._line.p1(), self._line.p2()) + penw = self.pen().width() + return r.adjusted(-penw, -penw, penw, penw) + + def paint(self, painter, *args): + if self._line is None: + self.boundingRect() + + painter.save() + painter.setPen(self.pen()) + painter.drawLine(self._line) + painter.restore() + +def clusters_at_height(root, height): + """Return a list of clusters by cutting the tree at `height`. + """ + selected = [] + covered = set() + for cl in preorder(root): + if cl in covered: + continue + if cl.value.height < height: + selected.append(cl) + covered.update(preorder(cl)) + + for leaf in leaves(root): + # NJ leaves can have positive heights; include leaves not already + # covered by an internal cluster below the cut. + if leaf not in covered: + selected.append(leaf) + + return selected + +def main(): + # pragma: no cover + from Orange import distance + data = Orange.data.Table("iris") + matrix = distance.Euclidean(data) + WidgetPreview(OWNeighborJoining).run(matrix) + +if __name__ == "__main__": # pragma: no cover + main() \ No newline at end of file diff --git a/orangecontrib/prototypes/widgets/tests/test_owneighborjoining.py b/orangecontrib/prototypes/widgets/tests/test_owneighborjoining.py new file mode 100644 index 00000000..c4d0b2fc --- /dev/null +++ b/orangecontrib/prototypes/widgets/tests/test_owneighborjoining.py @@ -0,0 +1,242 @@ +# Test methods with long descriptive names can omit docstrings +# pylint: disable=missing-docstring, protected-access +import unittest +import warnings + +import numpy as np + +import Orange.misc +from Orange.clustering.hierarchical import leaves +from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable +from Orange.distance import Euclidean +from Orange.misc import DistMatrix +from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin + +from orangecontrib.prototypes.widgets.owneighborjoining import ( + MAX_ITEMS, + OWNeighborJoining, +) + + +class TestOWNeighborJoining(WidgetTest, WidgetOutputsTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + WidgetOutputsTestMixin.init(cls) + + cls.distances = Euclidean(cls.data) + cls.distances_cols = Euclidean(cls.data, axis=0) + cls.signal_name = OWNeighborJoining.Inputs.distances + cls.signal_data = cls.distances + cls.same_input_output_domain = False + + def setUp(self): + self.widget = self.create_widget(OWNeighborJoining) + + def _select_first_non_leaf_cluster(self, widget=None): + widget = widget or self.widget + clusters = [ + node for node in widget.dendrogram._items + if not node.is_leaf + ] + self.assertTrue(clusters) + cluster = widget.dendrogram.item(clusters[0]) + widget.dendrogram.set_selected_items([cluster]) + widget.commit.now() + return [leaf.value.index for leaf in leaves(cluster.node)] + + def _select_data(self): + return self._select_first_non_leaf_cluster() + + def test_selection_box_output(self): + self.send_signal(self.widget.Inputs.distances, self.distances) + self.assertIsNone(self.get_output(self.widget.Outputs.selected_data)) + self.assertIsNotNone(self.get_output(self.widget.Outputs.annotated_data)) + + self.widget.selection_box.buttons[1].click() + self.assertIsNotNone(self.get_output(self.widget.Outputs.selected_data)) + self.assertIsNotNone(self.get_output(self.widget.Outputs.annotated_data)) + + self.widget.selection_box.buttons[2].click() + self.assertIsNotNone(self.get_output(self.widget.Outputs.selected_data)) + self.assertIsNotNone(self.get_output(self.widget.Outputs.annotated_data)) + + def test_data_input(self): + self.send_signal(self.widget.Inputs.data, self.data) + self.assertFalse(self.widget.Error.distance_computation_error.is_shown()) + self.assertFalse(self.widget.Error.no_numeric_features.is_shown()) + self.assertIsNotNone(self.widget.root) + self.assertIsNotNone(self.get_output(self.widget.Outputs.annotated_data)) + + def test_discrete_data_input(self): + domain = Domain([ + DiscreteVariable("a", values=("x", "y")), + DiscreteVariable("b", values=("u", "v")), + ]) + data = Table.from_numpy(domain, X=np.array([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ])) + + self.send_signal(self.widget.Inputs.data, data) + + self.assertFalse(self.widget.Error.no_numeric_features.is_shown()) + self.assertIsNotNone(self.widget.root) + + def test_no_input_features(self): + data = Table.from_numpy(Domain([], None), X=np.empty((3, 0))) + + self.send_signal(self.widget.Inputs.data, data) + + self.assertTrue(self.widget.Error.no_numeric_features.is_shown()) + self.assertIsNone(self.widget.root) + self.assertIsNone(self.get_output(self.widget.Outputs.selected_data)) + self.assertIsNone(self.get_output(self.widget.Outputs.annotated_data)) + + def test_all_zero_inputs(self): + d = Orange.misc.DistMatrix(np.zeros((10, 10))) + self.send_signal(self.widget.Inputs.distances, d) + self.assertFalse(self.widget.Error.tree_construction_error.is_shown()) + + def test_annotation_settings_retrieval(self): + widget = self.widget + + dist_names = Orange.misc.DistMatrix( + np.zeros((4, 4)), self.data, axis=0) + dist_no_names = Orange.misc.DistMatrix(np.zeros((10, 10)), axis=1) + + self.send_signal(self.widget.Inputs.distances, self.distances) + self.assertEqual(widget.annotation, self.data.domain.class_var) + + var2 = self.data.domain[2] + widget.annotation = var2 + + self.send_signal(self.widget.Inputs.distances, dist_no_names) + self.assertEqual(widget.annotation, "Enumeration") + widget.annotation = "None" + + self.send_signal(self.widget.Inputs.distances, self.distances) + self.assertIs(widget.annotation, var2) + self.send_signal(self.widget.Inputs.distances, dist_no_names) + self.assertEqual(widget.annotation, "None") + + self.send_signal(self.widget.Inputs.distances, dist_names) + self.assertEqual(widget.annotation, "Name") + widget.annotation = "Enumeration" + + self.send_signal(self.widget.Inputs.distances, self.distances) + self.assertIs(widget.annotation, var2) + self.send_signal(self.widget.Inputs.distances, dist_no_names) + self.assertEqual(widget.annotation, "None") + self.send_signal(self.widget.Inputs.distances, dist_names) + self.assertEqual(widget.annotation, "Enumeration") + self.send_signal(self.widget.Inputs.distances, dist_no_names) + self.assertEqual(widget.annotation, "None") + + def test_domain_loses_class(self): + self.send_signal(self.widget.Inputs.distances, self.distances) + data = self.data[:, :4] + distances = Euclidean(data) + self.send_signal(self.widget.Inputs.distances, distances) + self.assertIsNotNone(self.widget.root) + + def test_infinite_distances(self): + table = Table.from_list( + Domain( + [ContinuousVariable("a")], + [DiscreteVariable("b", values=("y", ))]), + list(zip([1.79e308, -1e120], "yy")) + ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", ".*", RuntimeWarning) + distances = Euclidean(table) + self.assertFalse(self.widget.Error.not_finite_distances.is_shown()) + self.send_signal(self.widget.Inputs.distances, distances) + self.assertTrue(self.widget.Error.not_finite_distances.is_shown()) + self.send_signal(self.widget.Inputs.distances, self.distances) + self.assertFalse(self.widget.Error.not_finite_distances.is_shown()) + + def test_not_symmetric(self): + w = self.widget + self.send_signal(w.Inputs.distances, DistMatrix([[1, 2, 3], [4, 5, 6]])) + self.assertTrue(w.Error.not_symmetric.is_shown()) + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.not_symmetric.is_shown()) + + def test_empty_matrix(self): + w = self.widget + self.send_signal(w.Inputs.distances, DistMatrix([[]])) + self.assertTrue(w.Error.empty_matrix.is_shown()) + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.empty_matrix.is_shown()) + + def test_too_many_items(self): + matrix = DistMatrix(np.zeros((MAX_ITEMS + 1, MAX_ITEMS + 1))) + + self.send_signal(self.widget.Inputs.distances, matrix) + + self.assertTrue(self.widget.Error.too_many_items.is_shown()) + self.assertIsNone(self.widget.root) + + def test_manual_selection_output(self): + self.send_signal(self.widget.Inputs.distances, self.distances) + + self._select_first_non_leaf_cluster() + + selected = self.get_output(self.widget.Outputs.selected_data) + annotated = self.get_output(self.widget.Outputs.annotated_data) + self.assertIsNotNone(selected) + self.assertIsNotNone(annotated) + self.assertLessEqual(len(selected), len(self.data)) + self.assertEqual(annotated.domain.variables, selected.domain.variables) + + def test_column_distances(self): + self.send_signal(self.widget.Inputs.distances, self.distances_cols) + + self._select_first_non_leaf_cluster() + + annotated = self.get_output(self.widget.Outputs.annotated_data) + self.assertIsNotNone(annotated) + self.assertEqual(len(annotated.domain.attributes), + len(self.data.domain.attributes)) + self.assertTrue(any( + "cluster" in attr.attributes + for attr in annotated.domain.attributes + )) + + def test_many_values_warning(self): + w = self.widget + + self.send_signal(self.widget.Inputs.distances, self.distances) + w.top_n = 21 + w.selection_box.buttons[2].click() + self.assertTrue(w.Warning.many_clusters.is_shown()) + + w.top_n = 20 + w.selection_box.buttons[2].click() + self.assertFalse(w.Warning.many_clusters.is_shown()) + + w.top_n = 21 + w.selection_box.buttons[2].click() + self.assertTrue(w.Warning.many_clusters.is_shown()) + + self.send_signal(self.widget.Inputs.distances, None) + self.assertFalse(w.Warning.many_clusters.is_shown()) + + def test_pruning_keeps_widget_operational(self): + self.send_signal(self.widget.Inputs.distances, self.distances) + + self.widget.pruning = 1 + self.widget.max_depth = 3 + self.widget._invalidate_pruning() + + self.assertIsNotNone(self.widget.root) + self.assertIsNotNone(self.widget._displayed_root) + self._select_first_non_leaf_cluster() + self.assertIsNotNone(self.get_output(self.widget.Outputs.annotated_data)) + + +if __name__ == "__main__": + unittest.main()