1from itertools import chain 2from contextlib import contextmanager 3 4import typing 5from typing import Any, List, Tuple, Dict, Optional, Set, Union 6 7import numpy as np 8 9from AnyQt.QtWidgets import ( 10 QGraphicsWidget, QGraphicsObject, QGraphicsScene, QGridLayout, QSizePolicy, 11 QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent 12) 13from AnyQt.QtGui import QColor, QPen, QFont, QKeySequence 14from AnyQt.QtCore import Qt, QSize, QSizeF, QPointF, QRectF, QLineF, QEvent 15from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot 16 17import pyqtgraph as pg 18 19import Orange.data 20from Orange.data.domain import filter_visible 21from Orange.data import Domain 22import Orange.misc 23from Orange.clustering.hierarchical import \ 24 postorder, preorder, Tree, tree_from_linkage, dist_matrix_linkage, \ 25 leaves, prune, top_clusters 26from Orange.data.util import get_unique_names 27 28from Orange.widgets import widget, gui, settings 29from Orange.widgets.utils import itemmodels, combobox 30from Orange.widgets.utils.annotated_data import (create_annotated_table, 31 ANNOTATED_DATA_SIGNAL_NAME) 32from Orange.widgets.utils.widgetpreview import WidgetPreview 33from Orange.widgets.widget import Input, Output, Msg 34 35from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView 36from Orange.widgets.utils.graphicstextlist import TextListWidget 37from Orange.widgets.utils.dendrogram import DendrogramWidget 38 39__all__ = ["OWHierarchicalClustering"] 40 41 42LINKAGE = ["Single", "Average", "Weighted", "Complete", "Ward"] 43 44 45def make_pen(brush=Qt.black, width=1, style=Qt.SolidLine, 46 cap_style=Qt.SquareCap, join_style=Qt.BevelJoin, 47 cosmetic=False): 48 pen = QPen(brush) 49 pen.setWidth(width) 50 pen.setStyle(style) 51 pen.setCapStyle(cap_style) 52 pen.setJoinStyle(join_style) 53 pen.setCosmetic(cosmetic) 54 return pen 55 56 57@contextmanager 58def blocked(obj): 59 old = obj.signalsBlocked() 60 obj.blockSignals(True) 61 try: 62 yield obj 63 finally: 64 obj.blockSignals(old) 65 66 67class SaveStateSettingsHandler(settings.SettingsHandler): 68 """ 69 A settings handler that delegates session data store/restore to the 70 OWWidget instance. 71 72 The OWWidget subclass must implement `save_state() -> Dict[str, Any]` and 73 `set_restore_state(state: Dict[str, Any])` methods. 74 """ 75 def initialize(self, instance, data=None): 76 super().initialize(instance, data) 77 if data is not None and "__session_state_data" in data: 78 session_data = data["__session_state_data"] 79 instance.set_restore_state(session_data) 80 81 def pack_data(self, widget): 82 # type: (widget.OWWidget) -> dict 83 res = super().pack_data(widget) 84 state = widget.save_state() 85 if state: 86 assert "__session_state_data" not in res 87 res["__session_state_data"] = state 88 return res 89 90 91class _DomainContextHandler(settings.DomainContextHandler, 92 SaveStateSettingsHandler): 93 pass 94 95 96if typing.TYPE_CHECKING: 97 #: Encoded selection state for persistent storage. 98 #: This is a list of tuples of leaf indices in the selection and 99 #: a (N, 3) linkage matrix for validation (the 4-th column from scipy 100 #: is omitted). 101 SelectionState = Tuple[List[Tuple[int]], List[Tuple[int, int, float]]] 102 103 104class OWHierarchicalClustering(widget.OWWidget): 105 name = "Hierarchical Clustering" 106 description = "Display a dendrogram of a hierarchical clustering " \ 107 "constructed from the input distance matrix." 108 icon = "icons/HierarchicalClustering.svg" 109 priority = 2100 110 keywords = [] 111 112 class Inputs: 113 distances = Input("Distances", Orange.misc.DistMatrix) 114 115 class Outputs: 116 selected_data = Output("Selected Data", Orange.data.Table, default=True) 117 annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table) 118 119 settingsHandler = _DomainContextHandler() 120 121 #: Selected linkage 122 linkage = settings.Setting(1) 123 #: Index of the selected annotation item (variable, ...) 124 annotation = settings.ContextSetting("Enumeration") 125 #: Out-of-context setting for the case when the "Name" option is available 126 annotation_if_names = settings.Setting("Name") 127 #: Out-of-context setting for the case with just "Enumerate" and "None" 128 annotation_if_enumerate = settings.Setting("Enumerate") 129 #: Selected tree pruning (none/max depth) 130 pruning = settings.Setting(0) 131 #: Maximum depth when max depth pruning is selected 132 max_depth = settings.Setting(10) 133 134 #: Selected cluster selection method (none, cut distance, top n) 135 selection_method = settings.Setting(0) 136 #: Cut height ratio wrt root height 137 cut_ratio = settings.Setting(75.0) 138 #: Number of top clusters to select 139 top_n = settings.Setting(3) 140 #: Dendrogram zoom factor 141 zoom_factor = settings.Setting(0) 142 143 autocommit = settings.Setting(True) 144 145 graph_name = "scene" 146 147 basic_annotations = ["None", "Enumeration"] 148 149 class Error(widget.OWWidget.Error): 150 not_finite_distances = Msg("Some distances are infinite") 151 152 #: Stored (manual) selection state (from a saved workflow) to restore. 153 __pending_selection_restore = None # type: Optional[SelectionState] 154 155 def __init__(self): 156 super().__init__() 157 158 self.matrix = None 159 self.items = None 160 self.linkmatrix = None 161 self.root = None 162 self._displayed_root = None 163 self.cutoff_height = 0.0 164 165 gui.comboBox( 166 self.controlArea, self, "linkage", items=LINKAGE, box="Linkage", 167 callback=self._invalidate_clustering) 168 169 model = itemmodels.VariableListModel() 170 model[:] = self.basic_annotations 171 172 box = gui.widgetBox(self.controlArea, "Annotations") 173 self.label_cb = cb = combobox.ComboBoxSearch( 174 minimumContentsLength=14, 175 sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon 176 ) 177 cb.setModel(model) 178 cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole)) 179 180 def on_annotation_activated(): 181 self.annotation = cb.currentData(Qt.EditRole) 182 self._update_labels() 183 cb.activated.connect(on_annotation_activated) 184 185 def on_annotation_changed(value): 186 cb.setCurrentIndex(cb.findData(value, Qt.EditRole)) 187 self.connect_control("annotation", on_annotation_changed) 188 189 box.layout().addWidget(self.label_cb) 190 191 box = gui.radioButtons( 192 self.controlArea, self, "pruning", box="Pruning", 193 callback=self._invalidate_pruning) 194 grid = QGridLayout() 195 box.layout().addLayout(grid) 196 grid.addWidget( 197 gui.appendRadioButton(box, "None", addToLayout=False), 198 0, 0 199 ) 200 self.max_depth_spin = gui.spin( 201 box, self, "max_depth", minv=1, maxv=100, 202 callback=self._invalidate_pruning, 203 keyboardTracking=False, addToLayout=False 204 ) 205 206 grid.addWidget( 207 gui.appendRadioButton(box, "Max depth:", addToLayout=False), 208 1, 0) 209 grid.addWidget(self.max_depth_spin, 1, 1) 210 211 self.selection_box = gui.radioButtons( 212 self.controlArea, self, "selection_method", 213 box="Selection", 214 callback=self._selection_method_changed) 215 216 grid = QGridLayout() 217 self.selection_box.layout().addLayout(grid) 218 grid.addWidget( 219 gui.appendRadioButton( 220 self.selection_box, "Manual", addToLayout=False), 221 0, 0 222 ) 223 grid.addWidget( 224 gui.appendRadioButton( 225 self.selection_box, "Height ratio:", addToLayout=False), 226 1, 0 227 ) 228 self.cut_ratio_spin = gui.spin( 229 self.selection_box, self, "cut_ratio", 0, 100, step=1e-1, 230 spinType=float, callback=self._selection_method_changed, 231 addToLayout=False 232 ) 233 self.cut_ratio_spin.setSuffix("%") 234 235 grid.addWidget(self.cut_ratio_spin, 1, 1) 236 237 grid.addWidget( 238 gui.appendRadioButton( 239 self.selection_box, "Top N:", addToLayout=False), 240 2, 0 241 ) 242 self.top_n_spin = gui.spin(self.selection_box, self, "top_n", 1, 20, 243 callback=self._selection_method_changed, 244 addToLayout=False) 245 grid.addWidget(self.top_n_spin, 2, 1) 246 247 self.zoom_slider = gui.hSlider( 248 self.controlArea, self, "zoom_factor", box="Zoom", 249 minValue=-6, maxValue=3, step=1, ticks=True, createLabel=False, 250 callback=self.__update_font_scale) 251 252 zoom_in = QAction( 253 "Zoom in", self, shortcut=QKeySequence.ZoomIn, 254 triggered=self.__zoom_in 255 ) 256 zoom_out = QAction( 257 "Zoom out", self, shortcut=QKeySequence.ZoomOut, 258 triggered=self.__zoom_out 259 ) 260 zoom_reset = QAction( 261 "Reset zoom", self, 262 shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0), 263 triggered=self.__zoom_reset 264 ) 265 self.addActions([zoom_in, zoom_out, zoom_reset]) 266 267 self.controlArea.layout().addStretch() 268 269 gui.auto_send(self.buttonsArea, self, "autocommit") 270 271 self.scene = QGraphicsScene(self) 272 self.view = StickyGraphicsView( 273 self.scene, 274 horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, 275 verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, 276 alignment=Qt.AlignLeft | Qt.AlignVCenter 277 ) 278 self.mainArea.layout().setSpacing(1) 279 self.mainArea.layout().addWidget(self.view) 280 281 def axis_view(orientation): 282 ax = AxisItem(orientation=orientation, maxTickLength=7) 283 ax.mousePressed.connect(self._activate_cut_line) 284 ax.mouseMoved.connect(self._activate_cut_line) 285 ax.mouseReleased.connect(self._activate_cut_line) 286 ax.setRange(1.0, 0.0) 287 return ax 288 289 self.top_axis = axis_view("top") 290 self.bottom_axis = axis_view("bottom") 291 292 self._main_graphics = QGraphicsWidget() 293 scenelayout = QGraphicsGridLayout() 294 scenelayout.setHorizontalSpacing(10) 295 scenelayout.setVerticalSpacing(10) 296 297 self._main_graphics.setLayout(scenelayout) 298 self.scene.addItem(self._main_graphics) 299 300 self.dendrogram = DendrogramWidget() 301 self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding, 302 QSizePolicy.MinimumExpanding) 303 self.dendrogram.selectionChanged.connect(self._invalidate_output) 304 self.dendrogram.selectionEdited.connect(self._selection_edited) 305 306 self.labels = TextListWidget() 307 self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) 308 self.labels.setAlignment(Qt.AlignLeft) 309 self.labels.setMaximumWidth(200) 310 311 scenelayout.addItem(self.top_axis, 0, 0, 312 alignment=Qt.AlignLeft | Qt.AlignVCenter) 313 scenelayout.addItem(self.dendrogram, 1, 0, 314 alignment=Qt.AlignLeft | Qt.AlignVCenter) 315 scenelayout.addItem(self.labels, 1, 1, 316 alignment=Qt.AlignLeft | Qt.AlignVCenter) 317 scenelayout.addItem(self.bottom_axis, 2, 0, 318 alignment=Qt.AlignLeft | Qt.AlignVCenter) 319 self.view.viewport().installEventFilter(self) 320 self._main_graphics.installEventFilter(self) 321 322 self.top_axis.setZValue(self.dendrogram.zValue() + 10) 323 self.bottom_axis.setZValue(self.dendrogram.zValue() + 10) 324 self.cut_line = SliderLine(self.top_axis, 325 orientation=Qt.Horizontal) 326 self.cut_line.valueChanged.connect(self._dendrogram_slider_changed) 327 self.dendrogram.geometryChanged.connect(self._dendrogram_geom_changed) 328 self._set_cut_line_visible(self.selection_method == 1) 329 self.__update_font_scale() 330 331 @Inputs.distances 332 def set_distances(self, matrix): 333 if self.__pending_selection_restore is not None: 334 selection_state = self.__pending_selection_restore 335 else: 336 # save the current selection to (possibly) restore later 337 selection_state = self._save_selection() 338 339 self.error() 340 self.Error.clear() 341 if matrix is not None: 342 N, _ = matrix.shape 343 if N < 2: 344 self.error("Empty distance matrix") 345 matrix = None 346 if matrix is not None: 347 if not np.all(np.isfinite(matrix)): 348 self.Error.not_finite_distances() 349 matrix = None 350 351 self.matrix = matrix 352 if matrix is not None: 353 self._set_items(matrix.row_items, matrix.axis) 354 else: 355 self._set_items(None) 356 self._invalidate_clustering() 357 358 # Can now attempt to restore session state from a saved workflow. 359 if self.root and selection_state is not None: 360 self._restore_selection(selection_state) 361 self.__pending_selection_restore = None 362 363 self.unconditional_commit() 364 365 def _set_items(self, items, axis=1): 366 self.closeContext() 367 self.items = items 368 model = self.label_cb.model() 369 if len(model) == 3: 370 self.annotation_if_names = self.annotation 371 elif len(model) == 2: 372 self.annotation_if_enumerate = self.annotation 373 if isinstance(items, Orange.data.Table) and axis: 374 model[:] = chain( 375 self.basic_annotations, 376 [model.Separator], 377 items.domain.class_vars, 378 items.domain.metas, 379 [model.Separator] if (items.domain.class_vars or items.domain.metas) and 380 next(filter_visible(items.domain.attributes), False) else [], 381 filter_visible(items.domain.attributes) 382 ) 383 if items.domain.class_vars: 384 self.annotation = items.domain.class_vars[0] 385 else: 386 self.annotation = "Enumeration" 387 self.openContext(items.domain) 388 else: 389 name_option = bool( 390 items is not None and ( 391 not axis or 392 isinstance(items, list) and 393 all(isinstance(var, Orange.data.Variable) for var in items))) 394 model[:] = self.basic_annotations + ["Name"] * name_option 395 self.annotation = self.annotation_if_names if name_option \ 396 else self.annotation_if_enumerate 397 398 def _clear_plot(self): 399 self.dendrogram.set_root(None) 400 self.labels.setItems([]) 401 402 def _set_displayed_root(self, root): 403 self._clear_plot() 404 self._displayed_root = root 405 self.dendrogram.set_root(root) 406 407 self._update_labels() 408 409 self._main_graphics.resize( 410 self._main_graphics.size().width(), 411 self._main_graphics.sizeHint(Qt.PreferredSize).height() 412 ) 413 self._main_graphics.layout().activate() 414 415 def _update(self): 416 self._clear_plot() 417 418 distances = self.matrix 419 420 if distances is not None: 421 method = LINKAGE[self.linkage].lower() 422 Z = dist_matrix_linkage(distances, linkage=method) 423 424 tree = tree_from_linkage(Z) 425 self.linkmatrix = Z 426 self.root = tree 427 428 self.top_axis.setRange(tree.value.height, 0.0) 429 self.bottom_axis.setRange(tree.value.height, 0.0) 430 431 if self.pruning: 432 self._set_displayed_root(prune(tree, level=self.max_depth)) 433 else: 434 self._set_displayed_root(tree) 435 else: 436 self.linkmatrix = None 437 self.root = None 438 self._set_displayed_root(None) 439 440 self._apply_selection() 441 442 def _update_labels(self): 443 labels = [] 444 if self.root and self._displayed_root: 445 indices = [leaf.value.index for leaf in leaves(self.root)] 446 447 if self.annotation == "None": 448 labels = [] 449 elif self.annotation == "Enumeration": 450 labels = [str(i+1) for i in indices] 451 elif self.annotation == "Name": 452 attr = self.matrix.row_items.domain.attributes 453 labels = [str(attr[i]) for i in indices] 454 elif isinstance(self.annotation, Orange.data.Variable): 455 col_data, _ = self.items.get_column_view(self.annotation) 456 labels = [self.annotation.str_val(val) for val in col_data] 457 labels = [labels[idx] for idx in indices] 458 else: 459 labels = [] 460 461 if labels and self._displayed_root is not self.root: 462 joined = leaves(self._displayed_root) 463 labels = [", ".join(labels[leaf.value.first: leaf.value.last]) 464 for leaf in joined] 465 466 self.labels.setItems(labels) 467 self.labels.setMinimumWidth(1 if labels else -1) 468 469 def _restore_selection(self, state): 470 # type: (SelectionState) -> bool 471 """ 472 Restore the (manual) node selection state. 473 474 Return True if successful; False otherwise. 475 """ 476 linkmatrix = self.linkmatrix 477 if self.selection_method == 0 and self.root: 478 selected, linksaved = state 479 linkstruct = np.array(linksaved, dtype=float) 480 selected = set(selected) # type: Set[Tuple[int]] 481 if not selected: 482 return False 483 if linkmatrix.shape[0] != linkstruct.shape[0]: 484 return False 485 # check that the linkage matrix structure matches. Use isclose for 486 # the height column to account for inexact floating point math 487 # (e.g. summation order in different ?gemm implementations for 488 # euclidean distances, ...) 489 if np.any(linkstruct[:, :2] != linkmatrix[:, :2]) or \ 490 not np.all(np.isclose(linkstruct[:, 2], linkstruct[:, 2])): 491 return False 492 selection = [] 493 indices = np.array([n.value.index for n in leaves(self.root)], 494 dtype=int) 495 # mapping from ranges to display (pruned) nodes 496 mapping = {node.value.range: node 497 for node in postorder(self._displayed_root)} 498 for node in postorder(self.root): # type: Tree 499 r = tuple(indices[node.value.first: node.value.last]) 500 if r in selected: 501 if node.value.range not in mapping: 502 # the node was pruned from display and cannot be 503 # selected 504 break 505 selection.append(mapping[node.value.range]) 506 selected.remove(r) 507 if not selected: 508 break # found all, nothing more to do 509 if selection and selected: 510 # Could not restore all selected nodes (only partial match) 511 return False 512 513 self._set_selected_nodes(selection) 514 return True 515 return False 516 517 def _set_selected_nodes(self, selection): 518 # type: (List[Tree]) -> None 519 """ 520 Set the nodes in `selection` to be the current selected nodes. 521 522 The selection nodes must be subtrees of the current `_displayed_root`. 523 """ 524 self.dendrogram.selectionChanged.disconnect(self._invalidate_output) 525 try: 526 self.dendrogram.set_selected_clusters(selection) 527 finally: 528 self.dendrogram.selectionChanged.connect(self._invalidate_output) 529 530 def _invalidate_clustering(self): 531 self._update() 532 self._update_labels() 533 self._invalidate_output() 534 535 def _invalidate_output(self): 536 self.commit() 537 538 def _invalidate_pruning(self): 539 if self.root: 540 selection = self.dendrogram.selected_nodes() 541 ranges = [node.value.range for node in selection] 542 if self.pruning: 543 self._set_displayed_root( 544 prune(self.root, level=self.max_depth)) 545 else: 546 self._set_displayed_root(self.root) 547 selected = [node for node in preorder(self._displayed_root) 548 if node.value.range in ranges] 549 550 self.dendrogram.set_selected_clusters(selected) 551 552 self._apply_selection() 553 554 def commit(self): 555 items = getattr(self.matrix, "items", self.items) 556 if not items: 557 self.Outputs.selected_data.send(None) 558 self.Outputs.annotated_data.send(None) 559 return 560 561 selection = self.dendrogram.selected_nodes() 562 selection = sorted(selection, key=lambda c: c.value.first) 563 564 indices = [leaf.value.index for leaf in leaves(self.root)] 565 566 maps = [indices[node.value.first:node.value.last] 567 for node in selection] 568 569 selected_indices = list(chain(*maps)) 570 unselected_indices = sorted(set(range(self.root.value.last)) - 571 set(selected_indices)) 572 573 if not selected_indices: 574 self.Outputs.selected_data.send(None) 575 annotated_data = create_annotated_table(items, []) \ 576 if self.selection_method == 0 and self.matrix.axis else None 577 self.Outputs.annotated_data.send(annotated_data) 578 return 579 580 selected_data = None 581 582 if isinstance(items, Orange.data.Table) and self.matrix.axis == 1: 583 # Select rows 584 c = np.zeros(self.matrix.shape[0]) 585 586 for i, indices in enumerate(maps): 587 c[indices] = i 588 c[unselected_indices] = len(maps) 589 590 mask = c != len(maps) 591 592 data, domain = items, items.domain 593 attrs = domain.attributes 594 classes = domain.class_vars 595 metas = domain.metas 596 597 var_name = get_unique_names(domain, "Cluster") 598 values = [f"C{i + 1}" for i in range(len(maps))] 599 600 clust_var = Orange.data.DiscreteVariable( 601 var_name, values=values + ["Other"]) 602 domain = Orange.data.Domain(attrs, classes, metas + (clust_var,)) 603 data = items.transform(domain) 604 data.get_column_view(clust_var)[0][:] = c 605 606 if selected_indices: 607 selected_data = data[mask] 608 clust_var = Orange.data.DiscreteVariable( 609 var_name, values=values) 610 selected_data.domain = Domain( 611 attrs, classes, metas + (clust_var, )) 612 613 elif isinstance(items, Orange.data.Table) and self.matrix.axis == 0: 614 # Select columns 615 domain = Orange.data.Domain( 616 [items.domain[i] for i in selected_indices], 617 items.domain.class_vars, items.domain.metas) 618 selected_data = items.from_table(domain, items) 619 data = None 620 621 self.Outputs.selected_data.send(selected_data) 622 annotated_data = create_annotated_table(data, selected_indices) 623 self.Outputs.annotated_data.send(annotated_data) 624 625 def eventFilter(self, obj, event): 626 if obj is self.view.viewport() and event.type() == QEvent.Resize: 627 # NOTE: not using viewport.width(), due to 'transient' scroll bars 628 # (macOS). Viewport covers the whole view, but QGraphicsView still 629 # scrolls left, right with scroll bar extent (other 630 # QAbstractScrollArea widgets behave as expected). 631 w_frame = self.view.frameWidth() 632 margin = self.view.viewportMargins() 633 w_scroll = self.view.verticalScrollBar().width() 634 width = (self.view.width() - w_frame * 2 - 635 margin.left() - margin.right() - w_scroll) 636 # layout with new width constraint 637 self.__layout_main_graphics(width=width) 638 elif obj is self._main_graphics and \ 639 event.type() == QEvent.LayoutRequest: 640 # layout preserving the width (vertical re layout) 641 self.__layout_main_graphics() 642 return super().eventFilter(obj, event) 643 644 @Slot(QPointF) 645 def _activate_cut_line(self, pos: QPointF): 646 """Activate cut line selection an set cut value to `pos.x()`.""" 647 self.selection_method = 1 648 self.cut_line.setValue(pos.x()) 649 self._selection_method_changed() 650 651 def onDeleteWidget(self): 652 super().onDeleteWidget() 653 self._clear_plot() 654 self.dendrogram.clear() 655 self.dendrogram.deleteLater() 656 657 def _dendrogram_geom_changed(self): 658 pos = self.dendrogram.pos_at_height(self.cutoff_height) 659 geom = self.dendrogram.geometry() 660 self._set_slider_value(pos.x(), geom.width()) 661 662 self.cut_line.setLength( 663 self.bottom_axis.geometry().bottom() 664 - self.top_axis.geometry().top() 665 ) 666 667 geom = self._main_graphics.geometry() 668 assert geom.topLeft() == QPointF(0, 0) 669 670 def adjustLeft(rect): 671 rect = QRectF(rect) 672 rect.setLeft(geom.left()) 673 return rect 674 margin = 3 675 self.scene.setSceneRect(geom) 676 self.view.setSceneRect(geom) 677 self.view.setHeaderSceneRect( 678 adjustLeft(self.top_axis.geometry()).adjusted(0, 0, 0, margin) 679 ) 680 self.view.setFooterSceneRect( 681 adjustLeft(self.bottom_axis.geometry()).adjusted(0, -margin, 0, 0) 682 ) 683 684 def _dendrogram_slider_changed(self, value): 685 p = QPointF(value, 0) 686 cl_height = self.dendrogram.height_at(p) 687 688 self.set_cutoff_height(cl_height) 689 690 def _set_slider_value(self, value, span): 691 with blocked(self.cut_line): 692 self.cut_line.setRange(0, span) 693 self.cut_line.setValue(value) 694 695 def set_cutoff_height(self, height): 696 self.cutoff_height = height 697 if self.root: 698 self.cut_ratio = 100 * height / self.root.value.height 699 self.select_max_height(height) 700 701 def _set_cut_line_visible(self, visible): 702 self.cut_line.setVisible(visible) 703 704 def select_top_n(self, n): 705 root = self._displayed_root 706 if root: 707 clusters = top_clusters(root, n) 708 self.dendrogram.set_selected_clusters(clusters) 709 710 def select_max_height(self, height): 711 root = self._displayed_root 712 if root: 713 clusters = clusters_at_height(root, height) 714 self.dendrogram.set_selected_clusters(clusters) 715 716 def _selection_method_changed(self): 717 self._set_cut_line_visible(self.selection_method == 1) 718 if self.root: 719 self._apply_selection() 720 721 def _apply_selection(self): 722 if not self.root: 723 return 724 725 if self.selection_method == 0: 726 pass 727 elif self.selection_method == 1: 728 height = self.cut_ratio * self.root.value.height / 100 729 self.set_cutoff_height(height) 730 pos = self.dendrogram.pos_at_height(height) 731 self._set_slider_value(pos.x(), self.dendrogram.size().width()) 732 elif self.selection_method == 2: 733 self.select_top_n(self.top_n) 734 735 def _selection_edited(self): 736 # Selection was edited by clicking on a cluster in the 737 # dendrogram view. 738 self.selection_method = 0 739 self._selection_method_changed() 740 self._invalidate_output() 741 742 def _save_selection(self): 743 # Save the current manual node selection state 744 selection_state = None 745 if self.selection_method == 0 and self.root: 746 assert self.linkmatrix is not None 747 linkmat = [(int(_0), int(_1), _2) 748 for _0, _1, _2 in self.linkmatrix[:, :3].tolist()] 749 nodes_ = self.dendrogram.selected_nodes() 750 # match the display (pruned) nodes back (by ranges) 751 mapping = {node.value.range: node for node in postorder(self.root)} 752 nodes = [mapping[node.value.range] for node in nodes_] 753 indices = [tuple(node.value.index for node in leaves(node)) 754 for node in nodes] 755 if nodes: 756 selection_state = (indices, linkmat) 757 return selection_state 758 759 def save_state(self): 760 # type: () -> Dict[str, Any] 761 """ 762 Save state for `set_restore_state` 763 """ 764 selection = self._save_selection() 765 res = {"version": (0, 0, 0)} 766 if selection is not None: 767 res["selection_state"] = selection 768 return res 769 770 def set_restore_state(self, state): 771 # type: (Dict[str, Any]) -> bool 772 """ 773 Restore session data from a saved state. 774 775 Parameters 776 ---------- 777 state : Dict[str, Any] 778 779 NOTE 780 ---- 781 This is method called while the instance (self) is being constructed, 782 even before its `__init__` is called. Consider `self` to be only a 783 `QObject` at this stage. 784 """ 785 if "selection_state" in state: 786 selection = state["selection_state"] 787 self.__pending_selection_restore = selection 788 return True 789 790 def __zoom_in(self): 791 def clip(minval, maxval, val): 792 return min(max(val, minval), maxval) 793 self.zoom_factor = clip(self.zoom_slider.minimum(), 794 self.zoom_slider.maximum(), 795 self.zoom_factor + 1) 796 self.__update_font_scale() 797 798 def __zoom_out(self): 799 def clip(minval, maxval, val): 800 return min(max(val, minval), maxval) 801 self.zoom_factor = clip(self.zoom_slider.minimum(), 802 self.zoom_slider.maximum(), 803 self.zoom_factor - 1) 804 self.__update_font_scale() 805 806 def __zoom_reset(self): 807 self.zoom_factor = 0 808 self.__update_font_scale() 809 810 def __layout_main_graphics(self, width=-1): 811 if width < 0: 812 # Preserve current width. 813 width = self._main_graphics.size().width() 814 preferred = self._main_graphics.effectiveSizeHint( 815 Qt.PreferredSize, constraint=QSizeF(width, -1)) 816 self._main_graphics.resize(QSizeF(width, preferred.height())) 817 mw = self._main_graphics.minimumWidth() + 4 818 self.view.setMinimumWidth(mw + self.view.verticalScrollBar().width()) 819 820 def __update_font_scale(self): 821 font = self.scene.font() 822 factor = (1.25 ** self.zoom_factor) 823 font = qfont_scaled(font, factor) 824 self._main_graphics.setFont(font) 825 826 def send_report(self): 827 annot = self.label_cb.currentText() 828 if isinstance(self.annotation, str): 829 annot = annot.lower() 830 if self.selection_method == 0: 831 sel = "manual" 832 elif self.selection_method == 1: 833 sel = "at {:.1f} of height".format(self.cut_ratio) 834 else: 835 sel = "top {} clusters".format(self.top_n) 836 self.report_items(( 837 ("Linkage", LINKAGE[self.linkage].lower()), 838 ("Annotation", annot), 839 ("Prunning", 840 self.pruning != 0 and "{} levels".format(self.max_depth)), 841 ("Selection", sel), 842 )) 843 self.report_plot() 844 845 846def qfont_scaled(font, factor): 847 scaled = QFont(font) 848 if font.pointSizeF() != -1: 849 scaled.setPointSizeF(font.pointSizeF() * factor) 850 elif font.pixelSize() != -1: 851 scaled.setPixelSize(int(font.pixelSize() * factor)) 852 return scaled 853 854 855class AxisItem(pg.AxisItem): 856 mousePressed = Signal(QPointF, Qt.MouseButton) 857 mouseMoved = Signal(QPointF, Qt.MouseButtons) 858 mouseReleased = Signal(QPointF, Qt.MouseButton) 859 860 #: \reimp 861 def wheelEvent(self, event): 862 event.ignore() # ignore event to propagate to the view -> scroll 863 864 def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: 865 self.mousePressed.emit(event.pos(), event.button()) 866 super().mousePressEvent(event) 867 event.accept() 868 869 def mouseMoveEvent(self, event): 870 self.mouseMoved.emit(event.pos(), event.buttons()) 871 super().mouseMoveEvent(event) 872 event.accept() 873 874 def mouseReleaseEvent(self, event): 875 self.mouseReleased.emit(event.pos(), event.button()) 876 super().mouseReleaseEvent(event) 877 event.accept() 878 879 880class SliderLine(QGraphicsObject): 881 """A movable slider line.""" 882 valueChanged = Signal(float) 883 884 linePressed = Signal() 885 lineMoved = Signal() 886 lineReleased = Signal() 887 rangeChanged = Signal(float, float) 888 889 def __init__(self, parent=None, orientation=Qt.Vertical, value=0.0, 890 length=10.0, **kwargs): 891 self._orientation = orientation 892 self._value = value 893 self._length = length 894 self._min = 0.0 895 self._max = 1.0 896 self._line = QLineF() # type: Optional[QLineF] 897 self._pen = QPen() 898 super().__init__(parent, **kwargs) 899 900 self.setAcceptedMouseButtons(Qt.LeftButton) 901 self.setPen(make_pen(brush=QColor(50, 50, 50), width=1, cosmetic=False, 902 style=Qt.DashLine)) 903 904 if self._orientation == Qt.Vertical: 905 self.setCursor(Qt.SizeVerCursor) 906 else: 907 self.setCursor(Qt.SizeHorCursor) 908 909 def setPen(self, pen: Union[QPen, Qt.GlobalColor, Qt.PenStyle]) -> None: 910 pen = QPen(pen) 911 if self._pen != pen: 912 self.prepareGeometryChange() 913 self._pen = pen 914 self._line = None 915 self.update() 916 917 def pen(self) -> QPen: 918 return QPen(self._pen) 919 920 def setValue(self, value: float): 921 value = min(max(value, self._min), self._max) 922 923 if self._value != value: 924 self.prepareGeometryChange() 925 self._value = value 926 self._line = None 927 self.valueChanged.emit(value) 928 929 def value(self) -> float: 930 return self._value 931 932 def setRange(self, minval: float, maxval: float) -> None: 933 maxval = max(minval, maxval) 934 if minval != self._min or maxval != self._max: 935 self._min = minval 936 self._max = maxval 937 self.rangeChanged.emit(minval, maxval) 938 self.setValue(self._value) 939 940 def setLength(self, length: float): 941 if self._length != length: 942 self.prepareGeometryChange() 943 self._length = length 944 self._line = None 945 946 def length(self) -> float: 947 return self._length 948 949 def setOrientation(self, orientation: Qt.Orientation): 950 if self._orientation != orientation: 951 self.prepareGeometryChange() 952 self._orientation = orientation 953 self._line = None 954 if self._orientation == Qt.Vertical: 955 self.setCursor(Qt.SizeVerCursor) 956 else: 957 self.setCursor(Qt.SizeHorCursor) 958 959 def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: 960 event.accept() 961 self.linePressed.emit() 962 963 def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: 964 pos = event.pos() 965 if self._orientation == Qt.Vertical: 966 self.setValue(pos.y()) 967 else: 968 self.setValue(pos.x()) 969 self.lineMoved.emit() 970 event.accept() 971 972 def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: 973 if self._orientation == Qt.Vertical: 974 self.setValue(event.pos().y()) 975 else: 976 self.setValue(event.pos().x()) 977 self.lineReleased.emit() 978 event.accept() 979 980 def boundingRect(self) -> QRectF: 981 if self._line is None: 982 if self._orientation == Qt.Vertical: 983 self._line = QLineF(0, self._value, self._length, self._value) 984 else: 985 self._line = QLineF(self._value, 0, self._value, self._length) 986 r = QRectF(self._line.p1(), self._line.p2()) 987 penw = self.pen().width() 988 return r.adjusted(-penw, -penw, penw, penw) 989 990 def paint(self, painter, *args): 991 if self._line is None: 992 self.boundingRect() 993 994 painter.save() 995 painter.setPen(self.pen()) 996 painter.drawLine(self._line) 997 painter.restore() 998 999 1000def clusters_at_height(root, height): 1001 """Return a list of clusters by cutting the clustering at `height`. 1002 """ 1003 lower = set() 1004 cluster_list = [] 1005 for cl in preorder(root): 1006 if cl in lower: 1007 continue 1008 if cl.value.height < height: 1009 cluster_list.append(cl) 1010 lower.update(preorder(cl)) 1011 return cluster_list 1012 1013 1014if __name__ == "__main__": # pragma: no cover 1015 from Orange import distance 1016 data = Orange.data.Table("iris") 1017 matrix = distance.Euclidean(distance._preprocess(data)) 1018 WidgetPreview(OWHierarchicalClustering).run(matrix) 1019