From c84f1260950a4d29d83d373200f57e8ceb7fc571 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Wed, 29 May 2024 21:32:39 +0200 Subject: [PATCH 01/85] display cos distance of each nugget in document view --- wannadb_ui/interactive_matching.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 6f8c2f24..ad1805f8 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -1,5 +1,6 @@ import logging +import numpy as np from PyQt6 import QtGui from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon, QTextCursor @@ -507,6 +508,10 @@ def __init__(self, suggestion_list_widget): self.text_label.setFont(CODE_FONT_BOLD) self.layout.addWidget(self.text_label) + self.distance_label = QLabel() + self.distance_label.setFont(CODE_FONT) + self.layout.addWidget(self.distance_label) + def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: self.suggestion_list_widget.interactive_matching_widget.document_widget.current_nugget = self.nugget self.suggestion_list_widget.interactive_matching_widget.document_widget._highlight_current_nugget() @@ -514,9 +519,14 @@ def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: def update_item(self, item, params=None): self.nugget = item + sanitized_text = self.nugget.text sanitized_text = sanitized_text.replace("\n", " ") + distance = np.round(self.nugget[CachedDistanceSignal], 3) + self.text_label.setText(sanitized_text) + self.distance_label.setText(str(distance)) + if self.nugget == params: self.setStyleSheet(f"background-color: {YELLOW}") self.suggestion_list_widget.interactive_matching_widget.document_widget.suggestion_list.scroll_area.horizontalScrollBar().setValue( From 244f06e2615a81bb6790fa4d0beeaeb3b4bf3f65 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Thu, 30 May 2024 23:46:25 +0200 Subject: [PATCH 02/85] add 3D grid to document widget which could later be used for visualizations --- requirements.txt | 6 ++++++ wannadb_ui/interactive_matching.py | 5 +++++ wannadb_ui/visualizations.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 wannadb_ui/visualizations.py diff --git a/requirements.txt b/requirements.txt index 70fa4762..79061a97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -329,5 +329,11 @@ xxhash==3.5.0 yarl==1.12.1 # via aiohttp +pyqtgraph==0.13.7 + +PyOpenGL==3.1.7 + +PyOpenGL_accelerate==3.1.7 + # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index ad1805f8..39a52fe1 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -9,6 +9,7 @@ from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW +from wannadb_ui.visualizations import EmbeddingVisualizerWidget logger = logging.getLogger(__name__) @@ -302,6 +303,10 @@ def __init__(self, interactive_matching_widget): self.suggestion_list.setFixedHeight(60) self.layout.addWidget(self.suggestion_list) + self.visualizer = EmbeddingVisualizerWidget() + self.visualizer.setFixedHeight(200) + self.layout.addWidget(self.visualizer) + self.buttons_widget = QWidget() self.buttons_widget_layout = QHBoxLayout(self.buttons_widget) self.buttons_widget_layout.setContentsMargins(0, 0, 0, 0) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py new file mode 100644 index 00000000..f34828ce --- /dev/null +++ b/wannadb_ui/visualizations.py @@ -0,0 +1,22 @@ +import pyqtgraph as pg +import pyqtgraph.opengl as gl +import numpy as np +from pyqtgraph.opengl import GLViewWidget + + +class EmbeddingVisualizerWidget(GLViewWidget): + + def __init__(self): + super(EmbeddingVisualizerWidget, self).__init__() + + grid = gl.GLGridItem() + self.addItem(grid) + + pts = [0, 0, 0] + + scatter = gl.GLScatterPlotItem(pos=np.array(pts), + color=pg.glColor((0, 6.5)), + size=3, + pxMode=True) + self.addItem(scatter) + From 8920206e29b69b39dd7c95063d6d3c9a9beed046 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 1 Jun 2024 14:10:53 +0200 Subject: [PATCH 03/85] Show the cosine similarity value beneath the nuggets names --- wannadb_ui/interactive_matching.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 39a52fe1..b4d706a2 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -4,7 +4,7 @@ from PyQt6 import QtGui from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon, QTextCursor -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ @@ -503,19 +503,19 @@ def __init__(self, suggestion_list_widget): self.suggestion_list_widget = suggestion_list_widget self.nugget = None - self.setFixedHeight(30) + self.setFixedHeight(45) self.setStyleSheet(f"background-color: {WHITE}") - self.layout = QHBoxLayout(self) + self.layout = QGridLayout(self) self.layout.setContentsMargins(10, 0, 10, 0) self.text_label = QLabel() self.text_label.setFont(CODE_FONT_BOLD) - self.layout.addWidget(self.text_label) + self.layout.addWidget(self.text_label, 0, 0) self.distance_label = QLabel() self.distance_label.setFont(CODE_FONT) - self.layout.addWidget(self.distance_label) + self.layout.addWidget(self.distance_label), 0, 1 def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: self.suggestion_list_widget.interactive_matching_widget.document_widget.current_nugget = self.nugget From 4885e34374aa8551bf06f7463689368eafb382a3 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 1 Jun 2024 17:25:41 +0200 Subject: [PATCH 04/85] Add bar chart, design and button need to be improved --- .../__pycache__/__init__.cpython-39.pyc | Bin 0 -> 181 bytes wannadb_ui/__pycache__/common.cpython-39.pyc | Bin 0 -> 6609 bytes .../__pycache__/document_base.cpython-39.pyc | Bin 0 -> 16825 bytes .../interactive_matching.cpython-39.pyc | Bin 0 -> 18097 bytes .../__pycache__/main_window.cpython-39.pyc | Bin 0 -> 19986 bytes .../__pycache__/start_menu.cpython-39.pyc | Bin 0 -> 3167 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 0 -> 3200 bytes .../__pycache__/wannadb_api.cpython-39.pyc | Bin 0 -> 13446 bytes wannadb_ui/interactive_matching.py | 26 +++++++-- wannadb_ui/visualizations.py | 52 ++++++++++++++++++ 10 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 wannadb_ui/__pycache__/__init__.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/common.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/document_base.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/main_window.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/start_menu.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/visualizations.cpython-39.pyc create mode 100644 wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc diff --git a/wannadb_ui/__pycache__/__init__.cpython-39.pyc b/wannadb_ui/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a57ea5d29beba77e81187e7ec3719db89339539 GIT binary patch literal 181 zcmYe~<>g`kf?0Q?(m?cM5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;x_ienx(7s(xx- zYNEbNesXDUYF+wk$yl?Vs=Spc4@ADPGXXgiEepfUS48Kl5SaM tv2JlmX-cI&R3yGMQ$IdFGcU6wK3=b&@)n0pZhlH>PO2Tq&d)&10063TFL3|> literal 0 HcmV?d00001 diff --git a/wannadb_ui/__pycache__/common.cpython-39.pyc b/wannadb_ui/__pycache__/common.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24442a79c742f1b4bc872b427478a6ba9a1c2391 GIT binary patch literal 6609 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 literal 0 HcmV?d00001 diff --git a/wannadb_ui/__pycache__/document_base.cpython-39.pyc b/wannadb_ui/__pycache__/document_base.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f76fe3c315bf5b375240751ceedc4d1bca7001ba GIT binary patch literal 16825 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 UauVU(Q@Inl?+JzRZRbw?7n&0&U;qFB literal 0 HcmV?d00001 diff --git a/wannadb_ui/__pycache__/main_window.cpython-39.pyc b/wannadb_ui/__pycache__/main_window.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae666df46abf506997beaf11e5c45480ded4fa82 GIT binary patch literal 19986 zcmcg!S#TRidd5u<1VQkoB#MMCO3(pHK4h=u*rrIzk}XjODQPvf76#cv5)=+L#1#zqD9J4En1Fgv2u^rQ;uu#amygZ@dA%Lr=w5PmR+skmgw68p=O_uj- z`^!_>RC!vPE@!k%`G9tS>w8NF%ZIc><-^+H@)7L_mnBLw<)hkBhWkp#%2_STaI$o~ zd_p@B4CukeN&VE*pq6_#Ao@k>lYmI+fvwpmVeK?Z21FVqX?+m+Ga|Sg$PRu+G+WBX z?AY}yHA{bFhpt<8&-Il}LoWzBb^UgsXw6qEqG%PX6+5y0_`bDV+^7^vcAVi-)yjs@ zVQ!5ZM6X|3t5&i-cHi|2#ge{IER?Dn$Xq;MeRR3-xLUJtqsxU=y@bLmg<|D)u_CGu z?d0`qMs>r`P4j%g0J_^nv7uWizEs(+SrSb)XeSq{YqhdovCbDv9r?tvRj`VtRa`TX z>tDWpxoGM0g|$t6;XKCFdu4IyX8yv}r4@T%d1Y?p=5qf0+znYU(o&E=fA#W$9lo)+ zU`Nkio|}K)o-jA75Azt#deJE3Hq}aA@OZLedyn@)=0Pi~YN%(P@K)U_>JN3NX0Nw~ zI~6M%vKO9y&!vi`8wJqufqtc6t!;WM2QW~>x}sNVZu`XTLZwnzI6rso(i$<*`+>+y z;XejI50Jn^*n?VFgtUkVYf%x=Vj`;bh?o`^JzB4bLl}DP^xXD#skp|2CDQ5z6Q~jy zd{*_1V#UlnELxixknYAxf{Pi0Lh>?%KrU?QrYT;_VKN`95uUUZ7(;K5wVyGn~v^zB;$yzq5M$ z78>xwUs_sOyfHVwa_QD${>t3S{6#z)BW}MRx3{AvG29+mBiYjPLdku86@qU^R%_;C zZH((6CH`7%=mCVTrEH|w6s|mknWbwfo3P{ge5Fv<^Laaw&zGyBRw6i=&)=^VN~)$O zPuNf#WiHtxow#U6h&c9GXUf|{?E|$(I#K0Wz{o*pIr;qV>}IvB&*~MuFzYCfIeXUH z%rEO@-Gsi(UNZ{!tm3^|dA3woed(1`4@oP;>Zu1s^OR}T#N%02lCKqK%dj~4hir0k z+mDS5ZZwLYnL-i|MiP;r^X0lWy`o8lKIRXh7LH!&VLhTp^%#t555w^%A*~lsR3|9m zmi4(%lAtF+t=}z4xzGThIO@_|5(9=oDgpkGHlz>ZUL#MS%Odni0If!;;QSus_afgXlAPa%{3P-`c>w=@IcaE9Vo(fy640i_ zuo%HLBSys-t_Q@pn85X**dzAhdPwXOleiuh`^6NlN5r(q;5s7?h=aHu6^F!OT#tz( zVg}c&I4X|edR%11aa>P`6XGPUC&ej|!}XMy6{m5{i8JCQTxZ40;uTy^i&w>2T+fKt z#Ot`eB)%lx!1ZNuPP~chE8?fbTe!X|-WKnCFZj*CdQi-X^PeQOv)>Fv0@`b^ck_0l zIR&kSC2Kh)IEvpqex`xM3N(Tu_%s*{>;$$#jj$DQ;iw2%vF`@HA9722c4W(t2;U1C zm(e5M=oJwWc^V1^DhJ(sl=7TQSbaMo5gh?X35y8e>0(Ru(fLBDq>GG+SvGSN7Qw2S zM{}R|tW`@@X4?(ju%kj>t!>z&vI``hX45=0#w={;c8{rB z7pfJ@AlbM3kz2MNm-OXLUAK%0Dx(%F`XkF8^JCanle8U2gByhkkP&pNP^#&67&Zn| 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 eB7Y~ZOmKv+Q0nl|@P7l^s<8V2 literal 0 HcmV?d00001 diff --git a/wannadb_ui/__pycache__/start_menu.cpython-39.pyc b/wannadb_ui/__pycache__/start_menu.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe98c086fdcfb5a29c99071d2f6ef4cb5ce22eec GIT binary patch literal 3167 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 literal 0 HcmV?d00001 diff --git a/wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc b/wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb0ff308621ee27d474e8b12ec27780310826e17 GIT binary patch literal 13446 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 None: self.suggestion_list_widget.interactive_matching_widget.document_widget._highlight_current_nugget() self.suggestion_list_widget.interactive_matching_widget.document_widget.custom_selection_item_widget.hide() + def update_item(self, item, params=None): self.nugget = item - sanitized_text = self.nugget.text - sanitized_text = sanitized_text.replace("\n", " ") - distance = np.round(self.nugget[CachedDistanceSignal], 3) + sanitized_text, distance = self.get_nugget_data() + print() + + self.text_label.setText(sanitized_text) self.distance_label.setText(str(distance)) @@ -539,6 +550,7 @@ def update_item(self, item, params=None): ) else: self.setStyleSheet(f"background-color: {LIGHT_YELLOW}") + self.suggestion_list_widget.interactive_matching_widget.document_widget.update_barchart(self.get_nugget_data()) def enable_input(self): pass @@ -546,6 +558,12 @@ def enable_input(self): def disable_input(self): pass + def get_nugget_data(self): + sanitized_text = self.nugget.text + sanitized_text = sanitized_text.replace("\n", " ") + distance = np.round(self.nugget[CachedDistanceSignal], 3) + return sanitized_text, distance + class CustomSelectionItemWidget(QWidget): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index f34828ce..3836b778 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,7 +1,15 @@ +from collections import OrderedDict + import pyqtgraph as pg import pyqtgraph.opengl as gl import numpy as np +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton +from matplotlib import pyplot as plt from pyqtgraph.opengl import GLViewWidget +import sys +from matplotlib.figure import Figure +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas + class EmbeddingVisualizerWidget(GLViewWidget): @@ -20,3 +28,47 @@ def __init__(self): pxMode=True) self.addItem(scatter) + +class BarChartVisualizerWidget(QWidget): + def __init__(self, parent=None): + super(BarChartVisualizerWidget, self).__init__(parent) + self.setFixedHeight(200) + + self.layout = QVBoxLayout(self) + self.button = QPushButton("Show Bar Chart with cosine values") + self.layout.addWidget(self.button) + + self.data = {} # Initialize data as an empty dictionary + self.button.clicked.connect(self.show_bar_chart) + + def append_data(self, data_tuple): + self.data[data_tuple[0]] = data_tuple[1] + + def show_bar_chart(self): + self.data = OrderedDict(sorted(self.data.items(), key=lambda x: x[1])) + self.plot_bar_chart(self.data) + + def plot_bar_chart(self, data): + fig = plt.Figure() + ax = fig.add_subplot(111) + bars = ax.bar(data.keys(), data.values()) + + for bar, nugget_text in zip(bars, data.keys()): + ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() / 2, nugget_text, + ha='center', va='center', rotation=90, fontsize=bar.get_width(), color='white') + + ax.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=True) + + for label in ax.get_xticklabels(): + label.set_visible(False) + fig.tight_layout() + + self.canvas = FigureCanvas(fig) + self.window = QWidget() + self.window.setWindowTitle("Bar Chart") + self.window.setGeometry(100, 100, 800, 600) + + layout = QVBoxLayout() + layout.addWidget(self.canvas) + self.window.setLayout(layout) + self.window.show() From 234b3f0fd9de4abe30449d625020901ffb986330 Mon Sep 17 00:00:00 2001 From: Johanna Herbst Date: Tue, 11 Jun 2024 12:10:21 +0200 Subject: [PATCH 05/85] add scatterplot add scatterplot to display cosine distance between points, click on one point to display corresponding text+distance --- wannadb_ui/interactive_matching.py | 8 +- wannadb_ui/visualizations.py | 123 ++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 37a81c83..2abc7f57 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -9,7 +9,7 @@ from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW -from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget +from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget logger = logging.getLogger(__name__) @@ -310,6 +310,8 @@ def __init__(self, interactive_matching_widget): self.visualizer.setFixedHeight(200) self.layout.addWidget(self.visualizer) + self.scatter_plot_widget = ScatterPlotVisualizerWidget() + self.layout.addWidget(self.scatter_plot_widget) self.buttons_widget = QWidget() self.buttons_widget_layout = QHBoxLayout(self.buttons_widget) @@ -331,6 +333,9 @@ def update_barchart(self, data): print("In update_barchart") self.cosine_barchart.append_data(data) + def update_scatter_plot(self, data): + print("In update_scatter_plot") + self.scatter_plot_widget.append_data(data) def _match_button_clicked(self): if self.current_nugget is None: @@ -551,6 +556,7 @@ def update_item(self, item, params=None): else: self.setStyleSheet(f"background-color: {LIGHT_YELLOW}") self.suggestion_list_widget.interactive_matching_widget.document_widget.update_barchart(self.get_nugget_data()) + self.suggestion_list_widget.interactive_matching_widget.document_widget.update_scatter_plot(self.get_nugget_data()) def enable_input(self): pass diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 3836b778..9d9eec4c 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -3,14 +3,13 @@ import pyqtgraph as pg import pyqtgraph.opengl as gl import numpy as np -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel, QSizePolicy from matplotlib import pyplot as plt from pyqtgraph.opengl import GLViewWidget import sys from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas - - +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar class EmbeddingVisualizerWidget(GLViewWidget): @@ -72,3 +71,121 @@ def plot_bar_chart(self, data): layout.addWidget(self.canvas) self.window.setLayout(layout) self.window.show() + + +class ScatterPlotVisualizerWidget(QWidget): + def __init__(self, parent=None): + super(ScatterPlotVisualizerWidget, self).__init__(parent) + self.layout = QVBoxLayout(self) + self.button = QPushButton("Show Scatter Plot with Cosine Distances") + self.layout.addWidget(self.button) + self.data = [] # Store data as a list of tuples + self.button.clicked.connect(self.show_scatter_plot) + self.scatter_plot_canvas = None + self.scatter_plot_toolbar = None + self.window = None + self.annotation = None + + def append_data(self, data_tuple): + self.data.append(data_tuple) + + def show_scatter_plot(self): + if not self.data: + return + + # Clear data to prevent duplication + self.data = list(set(self.data)) + + # Close existing scatter plot + if self.window is not None: + self.window.close() + + fig = Figure() + ax = fig.add_subplot(111) + texts, distances = zip(*self.data) + + # Round the distances to a fixed number of decimal places + rounded_distances = np.round(distances, 3) + + # Ensure consistent x-values for the same rounded distance + distance_map = {} + for original, rounded in zip(distances, rounded_distances): + if rounded not in distance_map: + distance_map[rounded] = original + + consistent_distances = [distance_map[rd] for rd in rounded_distances] + + # Generate jittered y-values for points with the same x-value + unique_distances = {} + for i, distance in enumerate(consistent_distances): + if distance not in unique_distances: + unique_distances[distance] = [] + unique_distances[distance].append(i) + + y = np.zeros(len(distances)) + for distance, indices in unique_distances.items(): + jitter = np.linspace(-0.4, 0.4, len(indices)) + for j, index in enumerate(indices): + y[index] = jitter[j] + + # Generating a list of colors for each point + num_points = len(distances) + colors = plt.cm.jet(np.linspace(0, 1, num_points)) + + # Plot the points + scatter = ax.scatter(rounded_distances, y, c=colors, alpha=0.75, picker=True) # Enable picking + + ax.set_xlabel("Cosine Distance") + ax.set_xlim(min(rounded_distances) - 0.05, max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility + ax.set_yticks([]) # Remove y-axis labels to avoid confusion + fig.tight_layout() + + # Create canvas + self.scatter_plot_canvas = FigureCanvas(fig) + + # Create a new window for the plot + self.window = QMainWindow() + self.window.setWindowTitle("Scatter Plot") + self.window.setGeometry(100, 100, 800, 600) + + # Set the central widget of the window to the canvas + self.window.setCentralWidget(self.scatter_plot_canvas) + + # Add NavigationToolbar to the window + self.scatter_plot_toolbar = NavigationToolbar(self.scatter_plot_canvas, self.window) + self.window.addToolBar(self.scatter_plot_toolbar) + + # Show the window + self.window.show() + self.scatter_plot_canvas.draw() + + # Create an annotation box + self.annotation = ax.annotate( + "", xy=(0, 0), xytext=(20, 20), + textcoords="offset points", bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->") + ) + self.annotation.set_visible(False) + + # Connect the pick event + self.scatter_plot_canvas.mpl_connect("pick_event", self.on_pick) + + # Store the data for use in the event handler + self.texts = texts + self.distances = rounded_distances + self.y = y + self.scatter = scatter + + def on_pick(self, event): + if event.artist != self.scatter: + return + + # Get index of the picked point + ind = event.ind[0] + + # Update annotation text and position + self.annotation.xy = (self.distances[ind], self.y[ind]) + text = f"Distance: {self.distances[ind]:.3f}\nText: {self.texts[ind]}" + self.annotation.set_text(text) + self.annotation.set_visible(True) + self.scatter_plot_canvas.draw_idle() From d627bd29c591123270aeb166cc6510a2874c077c Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 12 Jun 2024 11:26:15 +0200 Subject: [PATCH 06/85] Adjust buttons in the view, lay they side by side --- .../interactive_matching.cpython-39.pyc | Bin 18097 -> 18650 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 3200 -> 6240 bytes wannadb_ui/interactive_matching.py | 19 ++++++++++++------ wannadb_ui/visualizations.py | 3 ++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc b/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc index e6c3c9fc7e47ff515c4592bf79b0b0dddda25554..69c6d77c19052dfb3b64c1248a5af7c02a590295 100644 GIT binary patch delta 5479 zcma)9Yj9h|71m0MEK8OxKjTM?6~AKn?bxy7cM{u#lmr~|0s#@%x;KszJ)FH)AxQ(G z1V~$kLfGj*p)Gb2=%mn6FqA+`nY4w{X(!XpKw)k>48Lg8KeWuWKbT?Y`SvQd;yQpm z{=Db0=kA`z?%5X}V&*B9ulRf(8~yxgI=b)TzEkxbK2*qJ3&U(fOL6=oX?2R6o&vt(xdoq6^g`qKl-wjp$;vgy<5j z6bkLcC{@ddQ6?2S#7?%Bxy4B~=`+3CcSMhAlgU^#a#Xy>{>U8StbJXcS+ygg7>33t zW6Alq?VmALE#7hlnScIY&Jbe(alPxI4Xeo=rbpL|iG(s0(^TF9MxG|Aa5OQKGI$%Y z&N1FW|9JNT^kCE{%5(18ypD#!6w|1j|D_W1v$E6 zcc~1`UKk7jWFk(8BNcsKnSZ;%+#_DAXkc~XL&BhNRd%{$=nBP9<%?`sd|bKGRS&%e zkykZhg-+V}ztE{w7g^ynRQD5@PD49l@Q4_zewN)d|8eyvEGUDC;PL`MIe-J|06M?` zR0vO?(x1Zc5MU)>So8#L>*}Xr&}$XC6H(>|Azb{dTh|hXmWXIP0>M4vLZI2Z=*}MT z-@u!$>miyD7giUy%e}~4M1AqCQUX{mh{uJ!Hq|Lp!)bX_&Xgr3H@DoRyTy-c^_F#H zGw9%7hv{CxU03@^pO~miT%nImUtNDdd{8&AdAZDb1oMsq?go5?9NR0x^%YKJ404RA z;<5TYCDwHl$~$Ru+Csh^wqPl5MdI1d+QkcH<)W+MqqNG7+rqUH>m=4oY%m&++fG2X z=D6*^n%Sl~8?za~<2IvNb%CSiJme&43yoW5TUGZQV>TMEm@NSZl+kvAIN^4qgE;Nd zRws=*XI(0@e&?J_Tx|5D+ogJ!R9{OfYiGNSo&)O+^h)fTvrE&R()3!XyI$({lWzYP z==Mw90jWDkx`VXPT<~aYEp!l6d1j?Ua-~CZrNg8$EG{&zOb1QKoh>pp zWXy~n*f?85yiFN;ST1rjGsi3EjFEgSYtC3^&iEB`#z3hyic;PUoFVMn0d!{Gyyktj zQ*<@;^iFVjFtkDt0LU!%V3c((5E;s)8+)sAG)Xu8N%3UU(czn+ly$a)%mt(XhXHp2 z3IRs|4!}{sF|j>Z#u~)_V2u;!wwMd zjH-0ia~DKamPjO<%hFZEFbg6{J(|$MQwoplSGW=K(V7>V9`1vZ%j$s4+=(MIN=3NAO~hLI?x)j3)LatsGoDgHJ;Hhk&O5j{%lDUE%9nMWSuCN1ApnwR6>p&(Xx#{HFGQ zGMCKV`^DPM+U{9=XO6&h#IyvLt4M>V0IVwoqn80c2RtrLcGmQq!BFP%!kWz-uFb@h zh-P|l6^3KVlopc>i`4=?EzHir4IMPmbVX8JPjWsFD=z_Xo0$2!l873VcWPK(rdP&< z|A2J#`CVPNvGg}E=U#$Oy+a*3rTEBD$J7W%I}r3sZ}TUhvbcd)K`w6Zdmt+S(itdE zem~#=z|(*OfbSDzI;l`$97(B$bwhM^S5|xn@+C`rjd*kOk?w!kJLIj&h47ejXo`pc71uaDQ@BhXxZQMFC2X$XN`lh<9#|*`z)5@sK>Gfh7<~)i`!*p}3vz_om5=(i zRXmL05+{!l?0_35HqLX=RZ5k{8Ds2gFDhEYTFQEY)jW@cT^jql8aksmR-CNu% zc&mdXvM;_cu!o&Rdm?%VpG(Jx{b>Ut0kz}6B0!M9%%|tBp-^+0ISdMk znK{k*yU^+a$nfGYmn-qu#nVHl_sjBeTkj#Mx(fa z;kbdnLsO!UP*|uTgj>u4x)3w(a(Pi%7mM_SJW&1uB?mpqEZ@oFX31V;uD(us{8xZI zZ^$V#Nu9q5TGq=#PEo4M(s&S}gMeQ{-F?~1OLorD57%M0m15gym9vB$s}~=RRtR4 zU<3d%#Ij_NWm$#dB{Gy3O(dw-%k+4Mpn=2)6=(CQUSt!BD$1@ekg~ig-osFCMDA;$ zd^QU)7X6Zl!8^Bzn(XbXa?*B;E`UOWSh%!~X*XN5C%t delta 5062 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 diff --git a/wannadb_ui/__pycache__/visualizations.cpython-39.pyc b/wannadb_ui/__pycache__/visualizations.cpython-39.pyc index a561a4799e366757d89ba80882ef10fac50b7674..239c5c0fdfd8438fe137d1851ebd620a9d7a4f0e 100644 GIT binary patch literal 6240 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 IpQz9M2S7A3)c^nh delta 962 zcmZ8f&ubGw6yDh%+0AZ}CT-d@+S+QhHneJ`UMjH&7AxpM5JW~~-DJ1v%51XEZlr;L zLOly*5Jjoz$v>b6y?EA}Cog-^s~7R&Nd({9h!Gd&+xNcj&FnXC-Nhv*+1%qhl$jvvM#_Cy2Bm zxrUp4D7jg$xTDYtb`FsIk)#$1DhibvYnm72`QQzdk`*N%B=Gj*MM#iQ`dr~n|5A~9uC{M6cK<{|yF zmzu5+5|dA^)d8(t^yV%|$T#E*%0X5~QoMZ997Z2GN4cDAY7aCpSm#W!=ZdKAAn{IzMP zR)AX(GuEy9)2JRom_fi6@+AcH%(0HoA`A~0&Ia}x#Rz>Hq+l6}p{9QoE@U-3jCsx2 z@3bS1!$Y%S?{3`UPPoO(m=__R8xSWu(D)XknZcWvKu@%GHwb%tRJ1E|lZhEPamYF` z^BX(^GQWVJi?@|2Vu(+bg{xU4E+gR34yJ2p`|p?}Mm^}bUFNM3D2pZla Date: Thu, 13 Jun 2024 13:41:51 +0200 Subject: [PATCH 07/85] Add labels on click for bar chart --- .../interactive_matching.cpython-39.pyc | Bin 18650 -> 18580 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 6240 -> 7000 bytes wannadb_ui/interactive_matching.py | 2 - wannadb_ui/visualizations.py | 99 +++++++++++++----- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc b/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc index 69c6d77c19052dfb3b64c1248a5af7c02a590295..15cda513c7c8fbf152994210ecb58df3b15b0bfe 100644 GIT binary patch delta 749 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 delta 3034 zcmZ`*&2t<_6`!83o!!-bNS0)+y^c3=j6Jcf*n|X|5_Tm{;UqXB#wH(Aj#s1Uku>t` z&bnvx$*Q&rLx6*exQ1IQRjXr)qxl1%xI#`8P<Z^dTk zh<8NsdOYb(V%(0WI@8{?(iP&Pof)s8cnAD3?>N(LXuQZvKh=0CJo3=+nvlzU4Dwi5 zf_wsUg;yb0mHZrU>bEtwwnvV+OqPEjcql^tW)x)euALj~;z?=Wy6G>5u`J)Z9c_p2 zC2IteXEu|GPDbRqFY#jO8oD$x6E&#D1CvVH~&H;v{qVCh1JrqjPKYkV_JU6x>%`* zBec$GB9nR7;&Wfbu|$K8q0ftpkd>cEE8Bd=<3 z&La1(z6|N#c(v^-DpK7-_5SQ!oCJQHUIjCZ7A(D5n0Gpe54g+VJDSU zs+{(sFWLbe8PTJZJ+pC(#0wy^cvH%T#+v!RoMzrF)CRQf)29fgj8{d;#4$5%$ab|I zy{`{-&UV?3(Km)hpAF4jyMIG&&D58g-;-X6T>51Rkz|N(XHrLUfadVvZ;sKj?;IBgJ}%b zX)$GVwC^17GqkYEe|>&0L0I-iIS9}Rq1f}Fk=Divvfx@$U-6~B?n_4`nV&^TS2{~c zH%rmwVjPy@6*4y$lWgUCcVKV&n^B7CgOw;)kv8;M(ve1%^t^IR5HZqh7g5SBRm*#v zTxb~}AHos0A&p*~3Cb55f%A5{wn(KQ4b*}(Q8lTw*Zp`cOr^0M^`x1Fo0(@Vhgo}5 z7U{DQ@%&!3dd@3nQEaDQwrHG+K|sdyJUuiRZ*CXdK_X^~5B#o`;Z-XRFKNW22_O}r@$YRmx=P3qI^1y=p9V@&E} zY>YWTC9g zRxLY*V~W#H7HZenWd*AXG*zvsf}u`uhIE8W;w%vgkm_wgXeQ3*f33gq(uZJW$?tae z3rkQN|34&tH=i39m+2__2q5ecJc>&+;n3+8iF7Wi;HQvzCV$*$0w!mkOiTp2{T;HY zevu#u&?I7+i1>GZCy(VSO7wqI9^HD|{2=G3`U3KEy0 z$Mer?cU(iVKw5s>Tk$=+hcXLA?oYYyTNoE_6L|+j0!`I*FuD`;Jr%kRMz-@mmS#@5 zy5}q=n`yR%TGN+;ZT-Skmw6>$h-5=)-MmmfKk!PlMLS$a*SfQ#z?kH-G*F2`Mb`@A zBn?$7iJRoWdqn8E5?v5k47+QcQ21#3kYo!@ouI4Yuq!T;!il49+Vg`@e4j);Kr63e z^`@3zpMLqokDwIq6Imo;6Zwb;Wg!2meEQoW00-wezmp^}dS2XxLYiFo8`9ZNeh3*q z6M}$1+@T4%H+JTdWLxg!sEIzNk$XgbPUIJ4GcJ;~E)Q`_;?RCK2-EyO({KOqml%@; z^lJo-v{$1Hr;}_I*9xpRWeG=a_mT)0ChZ`J6ZB(~7FD@lA+ANl2cd1#rTp>H@9IsK z|M_S-Scj(1aLH?{?7kuD1f!5~i8Sas(N>KzY;o28}V($bZ&& zb?~*&Lg5oM?dzds0i#rvPobp(X!$^p);%f9NKG6DbmOGzAHgg3wjN~5!tbq!8Q6Gr z$oZ9H)0HVm(n4B$TVguDd+h8r0UtHtkiOLMGrB$EXi*eNONA^-G-RY(sW*N&J`0du z6KI$GPsh%_GDdc)wkyQ_gb4i(j(#H-h&v3@2siSx$Df;M5dbSN##gV8*Dw4Jg=fln diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 086a408a..fa0e8a59 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -337,11 +337,9 @@ def __init__(self, interactive_matching_widget): self.buttons_widget_layout.addWidget(self.match_button) def update_barchart(self, data): - print("In update_barchart") self.cosine_barchart.append_data(data) def update_scatter_plot(self, data): - print("In update_scatter_plot") self.scatter_plot_widget.append_data(data) def _match_button_clicked(self): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 8e97145f..9cfc52cd 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -3,14 +3,15 @@ import pyqtgraph as pg import pyqtgraph.opengl as gl import numpy as np -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel, QSizePolicy +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow from matplotlib import pyplot as plt +from matplotlib.patches import Rectangle from pyqtgraph.opengl import GLViewWidget -import sys from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar + class EmbeddingVisualizerWidget(GLViewWidget): def __init__(self): @@ -22,55 +23,96 @@ def __init__(self): pts = [0, 0, 0] scatter = gl.GLScatterPlotItem(pos=np.array(pts), - color=pg.glColor((0, 6.5)), - size=3, - pxMode=True) + color=pg.glColor((0, 6.5)), + size=3, + pxMode=True) self.addItem(scatter) class BarChartVisualizerWidget(QWidget): def __init__(self, parent=None): super(BarChartVisualizerWidget, self).__init__(parent) - self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.button = QPushButton("Show Bar Chart with cosine values") self.layout.addWidget(self.button) - self.data = {} # Initialize data as an empty dictionary + self.data = [] # Initialize data as an empty dictionary self.button.clicked.connect(self.show_bar_chart) + self.window = None def append_data(self, data_tuple): - self.data[data_tuple[0]] = data_tuple[1] + + # print(f"Shape of the data: {np.shape(self.data)}") + self.data.append(data_tuple) def show_bar_chart(self): - self.data = OrderedDict(sorted(self.data.items(), key=lambda x: x[1])) - self.plot_bar_chart(self.data) + if not self.data: + return + self.plot_bar_chart() - def plot_bar_chart(self, data): - fig = plt.Figure() - ax = fig.add_subplot(111) - bars = ax.bar(data.keys(), data.values()) + def _unique_nuggets(self): + min_dict = {} + for item in self.data: + key, value = item + if key not in min_dict or value < min_dict[key]: + min_dict[key] = value + self.data = [(key, min_dict[key]) for key in min_dict] - for bar, nugget_text in zip(bars, data.keys()): - ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() / 2, nugget_text, - ha='center', va='center', rotation=90, fontsize=bar.get_width(), color='white') + def plot_bar_chart(self): + # Clear data to prevent duplication + self._unique_nuggets() + print(self.data) + if self.window is not None: + self.window.close() + + fig = Figure() + ax = fig.add_subplot(111) + texts, distances = zip(*self.data) - ax.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=True) + # Round the distances to a fixed number of decimal places + rounded_distances = np.round(distances, 3) - for label in ax.get_xticklabels(): - label.set_visible(False) + self.bar = ax.bar(texts, rounded_distances, alpha=0.75, picker=True) + # Set x-axis labels invisible + ax.set_xticks([]) + # ... fig.tight_layout() - self.canvas = FigureCanvas(fig) - self.window = QWidget() + self.bar_chart_canvas = FigureCanvas(fig) + self.window = QMainWindow() self.window.setWindowTitle("Bar Chart") self.window.setGeometry(100, 100, 800, 600) + self.window.setCentralWidget(self.bar_chart_canvas) + + self.bar_chart_toolbar = NavigationToolbar(self.bar_chart_canvas, self.window) + self.window.addToolBar(self.bar_chart_toolbar) - layout = QVBoxLayout() - layout.addWidget(self.canvas) - self.window.setLayout(layout) self.window.show() + self.bar_chart_canvas.draw() + + # create annotation box + self.annotation = ax.annotate( + "", xy=(0, 0), xytext=(20, 20), + textcoords="offset points", bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->") + ) + self.annotation.set_visible(False) + self.bar_chart_canvas.mpl_connect('pick_event', self.on_pick) + + self.texts = texts + self.distances = rounded_distances + + def on_pick(self, event): + if isinstance(event.artist, Rectangle): + patch = event.artist + index = self.bar.get_children().index(patch) + text = f"Infomation Nugget: \n{self.texts[index]} \n\n Value: {self.distances[index]}" + self.annotation.set_text(text) + self.annotation.xy = (patch.get_x() + patch.get_width() / 2, + patch.get_height() / 2) + self.annotation.set_visible(True) + self.bar_chart_canvas.draw_idle() class ScatterPlotVisualizerWidget(QWidget): @@ -122,7 +164,7 @@ def show_scatter_plot(self): if distance not in unique_distances: unique_distances[distance] = [] unique_distances[distance].append(i) - + y = np.zeros(len(distances)) for distance, indices in unique_distances.items(): jitter = np.linspace(-0.4, 0.4, len(indices)) @@ -137,7 +179,8 @@ def show_scatter_plot(self): scatter = ax.scatter(rounded_distances, y, c=colors, alpha=0.75, picker=True) # Enable picking ax.set_xlabel("Cosine Distance") - ax.set_xlim(min(rounded_distances) - 0.05, max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility + ax.set_xlim(min(rounded_distances) - 0.05, + max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility ax.set_yticks([]) # Remove y-axis labels to avoid confusion fig.tight_layout() @@ -180,7 +223,7 @@ def show_scatter_plot(self): def on_pick(self, event): if event.artist != self.scatter: return - + print("SCATTER PLOT ", type(event)) # Get index of the picked point ind = event.ind[0] From 435673faf4249414497f3d96166c98f4dc230ff6 Mon Sep 17 00:00:00 2001 From: eneapane Date: Thu, 13 Jun 2024 17:41:41 +0200 Subject: [PATCH 08/85] Add colored bar charts --- .../interactive_matching.cpython-39.pyc | Bin 18580 -> 18565 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 7000 -> 7832 bytes wannadb_ui/interactive_matching.py | 6 --- wannadb_ui/visualizations.py | 40 ++++++++++++------ 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc b/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc index 15cda513c7c8fbf152994210ecb58df3b15b0bfe..e2cbd7947aa373df6b1ef438a934a5e8296fda84 100644 GIT binary patch delta 433 zcmZ9I-z$S*6vv zI>5NR4JCmIs%?D4tbA(>2+T{ZS?4&Hr7eDeI|_!kaWAvsK_Dx?+uXn>xjQcTH%N9I zVMq={{2VTt>iR+@O-4s>MK{rsb(0y~5~j!iOyv$sEajPwP2_53@;RPLn#^)0u;e;n zXFR89hTHHM`G(ihGt4SbM4^hl3`_8GW;01&Zp#0E$_gk28Jh~)M}JpYMSo3}V56*F z45c2`OJ?`MSbZ@qbeGaz_g-NQXKbVl>sRq~<70-|K}?qZnUdsi?<$-V`2(o0_* No~5gQ2g2bC{{Utfa;^XX delta 431 zcmZ8d%PT~282z2!ow@Fexy+q0$TJ&RkcEhh$I#5mqbTx-rUtocVq9#lyi%eRpM^s1 z#KO+wSCobSL3Xo{ow8R-;?6WH-{yR$bI#Y-**?J00o16fx|u!e7qiowx9VaoIBnFP zVUQ~84lyL|>f*o%Rn@;h7oYV$2@67LP&v*;VWTGDo@$zR@E~rRdx1Og+u{Pg$<=zr z87I+tjDFD*)HoEHX#YVejdu*-hORpTYX;w9U?$Tg%VA1MWOk?JFl5uQg)C<#pW_L` zWL8cLhH}Ercvf-gZar7`=&F^MWLCcI@Y<*)S&SF6v?uP(rhNZXmfu!1Br`Vqfy^nZ zfciSiDgueH-9{>ZX3Ch1FU_PO7B!+N=X_!|ZqP>OzE;H4e9o3itSZvOAziij=sdiD0>S?6!a~jfM9xOz+_p XjM8Le0p0W*sm7c5itIvidfWa0Gt_r9 diff --git a/wannadb_ui/__pycache__/visualizations.cpython-39.pyc b/wannadb_ui/__pycache__/visualizations.cpython-39.pyc index c7a19eec7c39ac77bf484a5f1597987d96ab0ca2..25e1d03a479dcabae5d1e20c4db2bc7ff634f54a 100644 GIT binary patch delta 3785 zcmZ`+O>7&-72erhl1qxDNQsiDUsJYgo3-IMO|3YI?8KIxIE5nzwwgMO8+y%INu-uP zo?SW;Lxm~i#5NK+aTf>*1O=#~hYCdt6h0Q{xrZi)9@=7i?4i}Am$vA&@0+D%)j&w> z$M@dMy!m_Yd&|Gf{N!}fOeA6oeHI5+7Pk&OOir*rK0JD(Gg!?OGECtLkJx&3s4ygD z!yc}V6h@>RwX@aH!kCm}B###+NFL$|p1i{fldmbfpQr99JY@}SXoV@#(|myR0V_uO zKGFwyhV+cob6mZuV3skyPB)Fl?~>GfMjELmAhiz?!b-k|S8^_G5B*;Yh~+ViTf z9zWrSZkx2YZPnLHv}j+i(c;nW9nwl2KQ?QZ9k*1kHqH#un18`nbyd{`LN&5tgMdbJ3%uQhDfkIv&<>~hQUwQ9LWV6NtMKgy|xSu0sip-(z3 zk}BLnv{XM$QxvTl_rt+#a@}H(oQq-15PPx^?8w2i3QMUOHN!;CyV0L{^fP8KHtqdV zZKZ=Sa`@{3aWA-7VfUMX|I@m$J>I z|EGM4Gc@9qcOmlEm*lbm;MyP#`9+4$z&0>C5^jgNZ zu3$@fT~6ieW!I{T*U@hPAZPdbkbByOFNZ z);ZVRst!*x$wp9N-Ft{rLP&9XGX~;P1E-MfpeP<%PWFhfKS%Y*NUV zODmQT2dSl%o~t`VrMau+YS}hL**f4wB`+aOR zb5LBOQQ~C+KUA{ojwSlM8-2sG2t$zvyn{B4(m>=O#T+8QRhQQ81oE0~-n8tVUCQ3) zefQ&`hA7uuUw79UmY0pc+YwVVocK}ZyS7+f@>O$Hd_YFo;-^KuS>qO`ROxZz)2a3v z^}RrkgPc<+zGEz=h6$5uTphzNeGL_nA7=w(O;H9UNsp;I^Zpw@KN+EMC_P9$=NAO7 z(qJ~i?xJjX-46_uQpx=0c#i_cGK;KqQlIE{QBP)BWE|G|$Q1}H$48mGMQqx-s zA@R*sMVupJk7LC$$>Jhlmpub+i zH=A~z!BGiWClL307A^Z>n;t!n%h+M>;pl|-bvXNIV4$Ri-@uUl-us!Eg!BzSY4U!T z$&Nmm4W;PU=qgLmS?OaUoAxG#o=v01gR)pjTt=5Sy?2L>b*@5O0DK?t7T^bfYk&d( zq#>>Yeh4T6ZU9VzR|Tjle>^DPdhnJ+!7gb?Sm@RVSOhEqo)A&gs&$%GOPH=D&Qp^g zYFVQ0_?m6i{1ED;;~O?*S;H(@VhlahQq|WgyGib|wR}cqt>~cjL%?pU z_z|hTQ5WTBw(8SUd5ctduL7NFhy0S2-J1)}8w-0!QQ2(@)cCK)j@WT(4Wc{&Nd|Nd(llTOfc5V=#@(~@joE6QH*M<-_}-Z! Vh^r{O2Fv0&pM14gG?h-h^FI~rQbGU# delta 2883 zcmai0U5p!76~6b**dC8Pv-YkxYwus2CT(I8q6V66lBV0HO;Tv1O+uC?n3Ug%uLgEP|;1QwWfk((Q;u+4lv)zWM5^LRW z&%Ni|bN|ly&iv%nzb@FWWtjy2rhn7A@ud2|o~6HjaQOY9-kzvWP{Ik%1f}+5eNxFr zu%|s$FDp3?BCAp zUSN&I?C6WJcGFvbMC(S|?>HRZD4D4?VxA8CFluz$y~Wco|6kC^#w%(U=gjA?;IV4D zS}xZ8$ZLz2VI)r7c7y)Tp-#YndZXCxcl=v@&*}7=O)m-;KoRBv2-V@@3|a!*IH-L8 z#XM9!K?xa<5!oh`M07w}Y>QGDX=$AFsdz!&pWHiS_sMm(scq_!G0F~f&bHZRYQS3N zKpUjE#&w>$rw@o@4%jHyB-`kLj`GTNll&vEbUI;gUg`VXi7<}RmL>J)QKvXFuZV&fmGz<_b8vZ zmj*x6U_U*Y;h%Z2N3$Hg&TTXB1zU9H2Pwe4oIn5{+_Utgix+GI`~&Zjkbl0m4DXt9 zgWjqO!Rt3}dP4p&Km9{%5B-{)veqtD#j9W!r+UJNxW+dWUseN+pc{GuxdI}Pb0_St zU}(kKhTjvDX!kbiGzhiW486!%i@<#-|80FNzi+=cjI$h~#qBh_u$E4uLc8HNW9F`j z7EZs3(~F|p?{JSptnC;-8>+{_Y!O;`3V<*J0t0@KS&&~^o|Tx%42B_-hn%xn0UGEv z>5ZMAOTi3mZ$b+{0*K(nTQp)@Ox%^fA6s02P%yU%2E;bu+6#5MWLLS?vnN^t`QV*@WK^46ky3PRpl zi?jIej?bgjx{2bd=Qmd)aTz@{5w7F3EEnz_$LE1pOC{Hs@@@kM!IQaBV^s(=($ViT zH&+!tsBjZu34p)_Asx-Ci!%RXF!{aWfo6>^)l5+Zy+8&N&mkb6iI)(NC{%<9d_=&rHxe#XG>B|jg{Csupmxqk!Ot*`hyzH$k3SthOiw6YT}Kyc6&+7Pef=o$Ho z@{!>iC}Q-9vk2!9un38_5WbG^Ho`jy7Z8>ZE+Sk4I4`aM){Z7MLr4CNHHagXqvD%r zW+J?ca24V6%*=Y7Y`^UZH}b?+Bfe(71 ze*kwa(XXr27Ij?U+Xx>Zq!Hdj_zr@D@IJx-!9`d>XdrMx&Jy9Fh}nmrB5*e zU9XjrniZg;{XbgKk;kVmyz>JXj5B@*3X~Vd>6RbC8DrW?lELis+fJ_wdF_Q{Kn%#w^rj{X$fptGG}5pvY$4my78H~U*+fo&qEbQI zH2zmCk#P);B|~Yr{t!wH^s=abNIuawQI{D(A==@P%qX{8TJobA3rm6`@yybOb>+=7 z%gg7!QN48G;&N5e*?mJ>+`?VsgNbDT$oKW07i(|>ibe%qNOG@=JuvA-h@f~xy;dK( zJLOs2M%6=vHH6*BPwoXD@4Uo7( zD18A{O6v268adD=;fY@oNhspCVDc4*D?N2mb!H5M*f`zp@_yhg!Z!v9rvdU%W%5u_ N%7D8+txzny`(I0^axMS> diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index fa0e8a59..5ed1d434 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -323,7 +323,6 @@ def __init__(self, interactive_matching_widget): self.buttons_widget = QWidget() self.buttons_widget_layout = QHBoxLayout(self.buttons_widget) self.buttons_widget_layout.setContentsMargins(0, 0, 0, 0) - # self.buttons_widget_layout.setAlignment(Qt.AlignmentFlag.AlignRight) self.layout.addWidget(self.buttons_widget) self.no_match_button = QPushButton("Value Not In Document") @@ -544,12 +543,7 @@ def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: def update_item(self, item, params=None): self.nugget = item - sanitized_text, distance = self.get_nugget_data() - print() - - - self.text_label.setText(sanitized_text) self.distance_label.setText(str(distance)) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 9cfc52cd..de875ccf 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -5,6 +5,7 @@ import numpy as np from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow from matplotlib import pyplot as plt +from matplotlib.colors import LinearSegmentedColormap from matplotlib.patches import Rectangle from pyqtgraph.opengl import GLViewWidget from matplotlib.figure import Figure @@ -12,6 +13,15 @@ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +def get_colors(distances, color_start='red', color_end='blue'): + cmap = LinearSegmentedColormap.from_list("CustomMap", [color_start, color_end]) + # Normalize the data for color mapping + norm = plt.Normalize(min(distances), max(distances)) + # Generate the colors based on the data + colors = [cmap(norm(value)) for value in distances] + return colors + + class EmbeddingVisualizerWidget(GLViewWidget): def __init__(self): @@ -36,14 +46,11 @@ def __init__(self, parent=None): self.layout.setContentsMargins(0, 0, 0, 0) self.button = QPushButton("Show Bar Chart with cosine values") self.layout.addWidget(self.button) - self.data = [] # Initialize data as an empty dictionary self.button.clicked.connect(self.show_bar_chart) self.window = None def append_data(self, data_tuple): - - # print(f"Shape of the data: {np.shape(self.data)}") self.data.append(data_tuple) def show_bar_chart(self): @@ -60,9 +67,7 @@ def _unique_nuggets(self): self.data = [(key, min_dict[key]) for key in min_dict] def plot_bar_chart(self): - # Clear data to prevent duplication self._unique_nuggets() - print(self.data) if self.window is not None: self.window.close() @@ -70,13 +75,12 @@ def plot_bar_chart(self): ax = fig.add_subplot(111) texts, distances = zip(*self.data) - # Round the distances to a fixed number of decimal places rounded_distances = np.round(distances, 3) - self.bar = ax.bar(texts, rounded_distances, alpha=0.75, picker=True) - # Set x-axis labels invisible + self.bar = ax.bar(texts, rounded_distances, alpha=0.75, picker=True, color=get_colors(distances)) ax.set_xticks([]) - # ... + ax.set_ylabel('Cosine Similarity', fontsize=15) + ax.set_xlabel('Information Nuggets', fontsize=15) fig.tight_layout() self.bar_chart_canvas = FigureCanvas(fig) @@ -86,12 +90,12 @@ def plot_bar_chart(self): self.window.setCentralWidget(self.bar_chart_canvas) self.bar_chart_toolbar = NavigationToolbar(self.bar_chart_canvas, self.window) + print(f"BAR CHART TOOLBAR TYPE: {type(self.bar_chart_toolbar)}") self.window.addToolBar(self.bar_chart_toolbar) self.window.show() self.bar_chart_canvas.draw() - # create annotation box self.annotation = ax.annotate( "", xy=(0, 0), xytext=(20, 20), textcoords="offset points", bbox=dict(boxstyle="round", fc="w"), @@ -103,17 +107,28 @@ def plot_bar_chart(self): self.texts = texts self.distances = rounded_distances + # todo after value is confirmed or value not in document, reinitialize data + #self.window.destroyed.connect(self.cleanup) + def on_pick(self, event): if isinstance(event.artist, Rectangle): patch = event.artist index = self.bar.get_children().index(patch) text = f"Infomation Nugget: \n{self.texts[index]} \n\n Value: {self.distances[index]}" self.annotation.set_text(text) - self.annotation.xy = (patch.get_x() + patch.get_width() / 2, - patch.get_height() / 2) + print(patch.get_x()) + print(patch.get_width()) + # if patch.get_x() + patch.get_width() > 20: + annotation_x = patch.get_x() + patch.get_width() / 2 + annotation_y = patch.get_height() / 2 + self.annotation.xy = (annotation_x, annotation_y) self.annotation.set_visible(True) self.bar_chart_canvas.draw_idle() + def cleanup(self): + self.data = [] + self.bar = None + class ScatterPlotVisualizerWidget(QWidget): def __init__(self, parent=None): @@ -223,7 +238,6 @@ def show_scatter_plot(self): def on_pick(self, event): if event.artist != self.scatter: return - print("SCATTER PLOT ", type(event)) # Get index of the picked point ind = event.ind[0] From 92827e3fabac5ea6d7dfcb3a880cd42f1c833f58 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Fri, 14 Jun 2024 12:21:55 +0200 Subject: [PATCH 09/85] show pca reduced embedding of attribute in DocumentWidget --- wannadb/data/signals.py | 14 ++++++ wannadb/preprocessing/dimension_reduction.py | 11 ++++ wannadb/preprocessing/embedding.py | 53 ++++++++++++++------ wannadb_ui/interactive_matching.py | 9 +++- wannadb_ui/visualizations.py | 32 +++++++++--- 5 files changed, 93 insertions(+), 26 deletions(-) create mode 100644 wannadb/preprocessing/dimension_reduction.py diff --git a/wannadb/data/signals.py b/wannadb/data/signals.py index 4afdcedf..02836170 100644 --- a/wannadb/data/signals.py +++ b/wannadb/data/signals.py @@ -356,6 +356,13 @@ class LabelEmbeddingSignal(BaseNumpyArraySignal): do_serialize: bool = True +@register_signal +class DimensionReducedLabelEmbeddingSignal(BaseNumpyArraySignal): + """Embedding of the nugget's label or attribute's name reduced to 3 dimensions.""" + identifier: str = "DimensionReducedLabelEmbeddingSignal" + do_serialize: bool = True + + @register_signal class TextEmbeddingSignal(BaseNumpyArraySignal): """Embedding of the nugget's text.""" @@ -363,6 +370,13 @@ class TextEmbeddingSignal(BaseNumpyArraySignal): do_serialize: bool = True +@register_signal +class DimensionReducedTextEmbeddingSignal(BaseNumpyArraySignal): + """Embedding of the nugget's text reduced to 3 dimensions.""" + identifier: str = "DimensionReducedTextEmbeddingSignal" + do_serialize: bool = True + + @register_signal class ContextSentenceEmbeddingSignal(BaseNumpyArraySignal): """Embedding of the nugget's textual context sentence.""" diff --git a/wannadb/preprocessing/dimension_reduction.py b/wannadb/preprocessing/dimension_reduction.py new file mode 100644 index 00000000..eec53c60 --- /dev/null +++ b/wannadb/preprocessing/dimension_reduction.py @@ -0,0 +1,11 @@ +from numpy import ndarray +from sklearn.decomposition import PCA + + +class PCAReduction: + + def __init__(self): + self.pca = PCA(n_components=3) + + def reduce_dimensions(self, data) -> ndarray: + return self.pca.fit_transform(data) diff --git a/wannadb/preprocessing/embedding.py b/wannadb/preprocessing/embedding.py index 2f0bd6c7..f90996ea 100644 --- a/wannadb/preprocessing/embedding.py +++ b/wannadb/preprocessing/embedding.py @@ -12,8 +12,9 @@ from wannadb.data.data import Attribute, DocumentBase, InformationNugget from wannadb.data.signals import ContextSentenceEmbeddingSignal, LabelEmbeddingSignal, RelativePositionSignal, \ TextEmbeddingSignal, UserProvidedExamplesSignal, NaturalLanguageLabelSignal, CachedContextSentenceSignal, \ - SentenceStartCharsSignal, DocumentSentenceEmbeddingSignal + SentenceStartCharsSignal, DocumentSentenceEmbeddingSignal, DimensionReducedLabelEmbeddingSignal from wannadb.interaction import BaseInteractionCallback +from wannadb.preprocessing.dimension_reduction import PCAReduction from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback @@ -80,7 +81,8 @@ def _call( self._embed_documents(document_base, interaction_callback, status_callback, statistics["documents"]) status_callback(f"Embedding documents with {self.identifier}...", 1) tack: float = time.time() - logger.info(f"Embedded {len(document_base.documents)} documents with {self.identifier} in {tack - tick} seconds.") + logger.info( + f"Embedded {len(document_base.documents)} documents with {self.identifier} in {tack - tick} seconds.") statistics["documents"]["runtime"] = tack - tick # compute embeddings for the nuggets @@ -94,13 +96,18 @@ def _call( if self.generated_signal_identifiers["nuggets"][0] in nuggets[0].signals.keys(): # Try to determine if the dimensions are correct (should match those of the embedding of the attributes) if len(self.generated_signal_identifiers["attributes"]) > 0: - if len(attributes) > 0 and attributes[0].signals[self.generated_signal_identifiers["attributes"][0]].value.shape == nuggets[0].signals[self.generated_signal_identifiers["attributes"][0]].value.shape: - logger.info(f"No need to embedd nuggets again with {self.identifier}, existing embeddings with correct dimensions found.") + if len(attributes) > 0 and attributes[0].signals[ + self.generated_signal_identifiers["attributes"][0]].value.shape == nuggets[0].signals[ + self.generated_signal_identifiers["attributes"][0]].value.shape: + logger.info( + f"No need to embedd nuggets again with {self.identifier}, existing embeddings with correct dimensions found.") return - logger.info(f"Dimension missmatch, recomputing embeddings for {self.generated_signal_identifiers['nuggets'][0]} with {self.identifier}.") + logger.info( + f"Dimension missmatch, recomputing embeddings for {self.generated_signal_identifiers['nuggets'][0]} with {self.identifier}.") else: # Cannot check dimensions, but assuming they are correct do to lack of other evidence - logger.info(f"Found existing embeddings for {self.generated_signal_identifiers['nuggets'][0]}, assuming they were created with {self.identifier} (even though dimension check is not possible.") + logger.info( + f"Found existing embeddings for {self.generated_signal_identifiers['nuggets'][0]}, assuming they were created with {self.identifier} (even though dimension check is not possible.") return # If no existing embeddings are found, or dimensions are not matching continue with embedding @@ -165,6 +172,12 @@ def _embed_documents( """ pass # default behavior: do nothing + def _apply_dimension_reduction(self, data: List[np.ndarray]) -> List[np.ndarray]: + red = PCAReduction() + result: np.ndarray = red.reduce_dimensions(data) + return [embedding for embedding in result] + + ######################################################################################################################## # actual embedders ######################################################################################################################## @@ -245,9 +258,11 @@ def _embed_attributes( embeddings: List[np.ndarray] = resources.MANAGER[self._sbert_resource_identifier].encode( texts, show_progress_bar=False ) + dim_reduced_embeddings: List[np.ndarray] = self._apply_dimension_reduction(embeddings) - for attribute, embedding in zip(attributes, embeddings): + for attribute, embedding, dim_reduced_embedding in zip(attributes, embeddings, dim_reduced_embeddings): attribute[LabelEmbeddingSignal] = LabelEmbeddingSignal(embedding) + attribute[DimensionReducedLabelEmbeddingSignal] = DimensionReducedLabelEmbeddingSignal(dim_reduced_embedding) @register_configurable_element @@ -513,13 +528,16 @@ def get_candidate_contexts(context_sentence, start_in_context, end_in_context): """ prev_candidate_context = None for candidate_start, candidate_end in zip(map(lambda i: max(0, i), count(start_in_context, -1)), - map(lambda i: min(i, len(context_sentence) - 1), count(end_in_context, 1))): + map(lambda i: min(i, len(context_sentence) - 1), + count(end_in_context, 1))): candidate_context = context_sentence[candidate_start:candidate_end] yield prev_candidate_context, candidate_context prev_candidate_context = candidate_context - for prev, candidate_context in get_candidate_contexts(context_sentence, start_in_context, end_in_context): - input_ids, token_type_ids, attention_mask, char_to_token = get_encoding_data(candidate_context, device) + for prev, candidate_context in get_candidate_contexts(context_sentence, start_in_context, + end_in_context): + input_ids, token_type_ids, attention_mask, char_to_token = get_encoding_data(candidate_context, + device) # The condition will be true at some point # because token_type_ids[0] is monotonically increasing with longer context sentences # and we know that the whole sentence is above the limit @@ -533,16 +551,19 @@ def get_candidate_contexts(context_sentence, start_in_context, end_in_context): logger.error(error) raise RuntimeError(error) context_sentence = prev - input_ids, token_type_ids, attention_mask, char_to_token = get_encoding_data(context_sentence, device) + input_ids, token_type_ids, attention_mask, char_to_token = get_encoding_data( + context_sentence, device) break - logger.error(f"==> Using shorter context sentence '{context_sentence}' with {len(token_type_ids[0])} token indices " - f"for nugget '{context_sentence[start_in_context:end_in_context]}'.") + logger.error( + f"==> Using shorter context sentence '{context_sentence}' with {len(token_type_ids[0])} token indices " + f"for nugget '{context_sentence[start_in_context:end_in_context]}'.") return input_ids, token_type_ids, attention_mask, char_to_token, context_sentence - input_ids, token_type_ids, attention_mask, char_to_token, context_sentence = get_encoding_data_with_limited_tokens_for_context(context_sentence, - start_in_context, - end_in_context) + input_ids, token_type_ids, attention_mask, char_to_token, context_sentence = get_encoding_data_with_limited_tokens_for_context( + context_sentence, + start_in_context, + end_in_context) outputs = resources.MANAGER[self._bert_resource_identifier]["model"]( input_ids=input_ids, diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 5ed1d434..d91199d0 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -6,7 +6,7 @@ from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy -from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal +from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, DimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget @@ -44,8 +44,10 @@ def disable_input(self): self.document_widget.disable_input() def handle_feedback_request(self, feedback_request): - self.header.setText(f"Attribute: {feedback_request['attribute'].name}") + attribute = feedback_request['attribute'] + self.header.setText(f"Attribute: {attribute.name}") self.nugget_list_widget.update_nuggets(feedback_request) + self.document_widget.update_attribute(attribute) self.enable_input() self.show_nugget_list_widget() @@ -513,6 +515,9 @@ def disable_input(self): self.no_match_button.setDisabled(True) self.suggestion_list.disable_input() + def update_attribute(self, attribute): + self.visualizer.update_grid(attribute[DimensionReducedLabelEmbeddingSignal]) + class SuggestionListItemWidget(CustomScrollableListItem): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index de875ccf..aa814ccd 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -3,11 +3,10 @@ import pyqtgraph as pg import pyqtgraph.opengl as gl import numpy as np -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel from matplotlib import pyplot as plt from matplotlib.colors import LinearSegmentedColormap from matplotlib.patches import Rectangle -from pyqtgraph.opengl import GLViewWidget from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar @@ -22,21 +21,38 @@ def get_colors(distances, color_start='red', color_end='blue'): return colors -class EmbeddingVisualizerWidget(GLViewWidget): +def add_grids(widget): + grid_xy = gl.GLGridItem() + widget.addItem(grid_xy) + + grid_xz = gl.GLGridItem() + grid_xz.rotate(90, 1, 0, 0) + widget.addItem(grid_xz) + + grid_yz = gl.GLGridItem() + grid_yz.rotate(90, 0, 1, 0) + widget.addItem(grid_yz) + + +class EmbeddingVisualizerWidget(QWidget): def __init__(self): super(EmbeddingVisualizerWidget, self).__init__() - grid = gl.GLGridItem() - self.addItem(grid) + layout = QVBoxLayout() + self.setLayout(layout) + + self.gl_widget = gl.GLViewWidget() + layout.addWidget(self.gl_widget) - pts = [0, 0, 0] + add_grids(self.gl_widget) - scatter = gl.GLScatterPlotItem(pos=np.array(pts), + def update_grid(self, new_points_to_display): + scatter = gl.GLScatterPlotItem(pos=np.array(new_points_to_display), color=pg.glColor((0, 6.5)), size=3, pxMode=True) - self.addItem(scatter) + self.gl_widget.addItem(scatter) class BarChartVisualizerWidget(QWidget): From 79cb3a5f8ae2b482f6b23e9ff72faf7722bdf621 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Fri, 14 Jun 2024 12:44:14 +0200 Subject: [PATCH 10/85] fix type of point passed to update grid leading to wrong points being displayed --- wannadb_ui/interactive_matching.py | 3 ++- wannadb_ui/visualizations.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index d91199d0..ec108e36 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -516,7 +516,8 @@ def disable_input(self): self.suggestion_list.disable_input() def update_attribute(self, attribute): - self.visualizer.update_grid(attribute[DimensionReducedLabelEmbeddingSignal]) + point_to_display = np.array([attribute[DimensionReducedLabelEmbeddingSignal]]) + self.visualizer.update_grid(point_to_display) class SuggestionListItemWidget(CustomScrollableListItem): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index aa814ccd..5cb1597d 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -47,9 +47,9 @@ def __init__(self): add_grids(self.gl_widget) - def update_grid(self, new_points_to_display): - scatter = gl.GLScatterPlotItem(pos=np.array(new_points_to_display), - color=pg.glColor((0, 6.5)), + def update_grid(self, points_to_display): + scatter = gl.GLScatterPlotItem(pos=points_to_display, + color=pg.glColor((0, 10)), size=3, pxMode=True) self.gl_widget.addItem(scatter) From 1cd17414eed4131add33f423269eab574cc2f763 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sun, 16 Jun 2024 13:54:41 +0200 Subject: [PATCH 11/85] refactor dim_red_value computation and add nugget embeddings to grid --- wannadb/preprocessing/dimension_reduction.py | 71 +++++++++++++++++++- wannadb/preprocessing/embedding.py | 12 +--- wannadb_ui/interactive_matching.py | 14 +++- wannadb_ui/visualizations.py | 13 +++- wannadb_ui/wannadb_api.py | 8 ++- 5 files changed, 100 insertions(+), 18 deletions(-) diff --git a/wannadb/preprocessing/dimension_reduction.py b/wannadb/preprocessing/dimension_reduction.py index eec53c60..86567961 100644 --- a/wannadb/preprocessing/dimension_reduction.py +++ b/wannadb/preprocessing/dimension_reduction.py @@ -1,11 +1,78 @@ +import logging +from abc import ABC +from typing import Dict, Any + from numpy import ndarray from sklearn.decomposition import PCA +from wannadb.configuration import BasePipelineElement, register_configurable_element +from wannadb.data.data import DocumentBase +from wannadb.data.signals import LabelEmbeddingSignal, TextEmbeddingSignal, DimensionReducedLabelEmbeddingSignal, \ + DimensionReducedTextEmbeddingSignal +from wannadb.interaction import BaseInteractionCallback +from wannadb.statistics import Statistics +from wannadb.status import BaseStatusCallback + +logger = logging.getLogger(__name__) + + +class DimensionReducer(BasePipelineElement, ABC): + identifier: str = "DimensionReducer" + + def __init__(self): + super(DimensionReducer, self).__init__() + + def _call(self, document_base: DocumentBase, interaction_callback: BaseInteractionCallback, + status_callback: BaseStatusCallback, statistics: Statistics) -> None: + pass + + def reduce_dimensions(self, data) -> ndarray: + pass + -class PCAReduction: +@register_configurable_element +class PCAReducer(DimensionReducer): + identifier: str = "PCAReducer" def __init__(self): + super().__init__() self.pca = PCA(n_components=3) + def __call__( + self, + document_base: DocumentBase, + interaction_callback: BaseInteractionCallback, + status_callback: BaseStatusCallback, + statistics: Statistics + ) -> None: + #Assume that all embeddings have same number of features + attribute_embeddings = [attribute[LabelEmbeddingSignal] for attribute in document_base.attributes] + nugget_embeddings = [nugget[TextEmbeddingSignal] for nugget in document_base.nuggets] + all_embeddings = attribute_embeddings + nugget_embeddings + + if len(all_embeddings) < 3: + logger.warning("Not enough data to apply dimension reduction, will not compute them.") + return + + dimension_reduced_embeddings = self.reduce_dimensions(all_embeddings) + + for idx, embedding in enumerate(dimension_reduced_embeddings): + if idx < len(attribute_embeddings): + document_base.attributes[idx][DimensionReducedLabelEmbeddingSignal] = ( + DimensionReducedLabelEmbeddingSignal(embedding)) + else: + document_base.nuggets[idx - len(attribute_embeddings)][DimensionReducedTextEmbeddingSignal] = ( + DimensionReducedLabelEmbeddingSignal(embedding)) + def reduce_dimensions(self, data) -> ndarray: - return self.pca.fit_transform(data) + self.pca.fit(data) + return self.pca.transform(data) + + def to_config(self) -> Dict[str, Any]: + return { + "identifier": self.identifier + } + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> "DimensionReducer": + return cls() diff --git a/wannadb/preprocessing/embedding.py b/wannadb/preprocessing/embedding.py index f90996ea..ecef72b6 100644 --- a/wannadb/preprocessing/embedding.py +++ b/wannadb/preprocessing/embedding.py @@ -12,9 +12,8 @@ from wannadb.data.data import Attribute, DocumentBase, InformationNugget from wannadb.data.signals import ContextSentenceEmbeddingSignal, LabelEmbeddingSignal, RelativePositionSignal, \ TextEmbeddingSignal, UserProvidedExamplesSignal, NaturalLanguageLabelSignal, CachedContextSentenceSignal, \ - SentenceStartCharsSignal, DocumentSentenceEmbeddingSignal, DimensionReducedLabelEmbeddingSignal + SentenceStartCharsSignal, DocumentSentenceEmbeddingSignal from wannadb.interaction import BaseInteractionCallback -from wannadb.preprocessing.dimension_reduction import PCAReduction from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback @@ -172,11 +171,6 @@ def _embed_documents( """ pass # default behavior: do nothing - def _apply_dimension_reduction(self, data: List[np.ndarray]) -> List[np.ndarray]: - red = PCAReduction() - result: np.ndarray = red.reduce_dimensions(data) - return [embedding for embedding in result] - ######################################################################################################################## # actual embedders @@ -258,11 +252,9 @@ def _embed_attributes( embeddings: List[np.ndarray] = resources.MANAGER[self._sbert_resource_identifier].encode( texts, show_progress_bar=False ) - dim_reduced_embeddings: List[np.ndarray] = self._apply_dimension_reduction(embeddings) - for attribute, embedding, dim_reduced_embedding in zip(attributes, embeddings, dim_reduced_embeddings): + for attribute, embedding in zip(attributes, embeddings): attribute[LabelEmbeddingSignal] = LabelEmbeddingSignal(embedding) - attribute[DimensionReducedLabelEmbeddingSignal] = DimensionReducedLabelEmbeddingSignal(dim_reduced_embedding) @register_configurable_element diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index ec108e36..52037a1c 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -6,7 +6,8 @@ from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy -from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, DimensionReducedLabelEmbeddingSignal +from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ + DimensionReducedLabelEmbeddingSignal, DimensionReducedTextEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget @@ -439,6 +440,7 @@ def update_document(self, nugget): self.nuggets_sorted_by_distance = list(sorted(self.document.nuggets, key=lambda x: x[CachedDistanceSignal])) self.nuggets_in_order = list(sorted(self.document.nuggets, key=lambda x: x.start_char)) self.custom_selection_item_widget.hide() + self.update_nuggets(self.document.nuggets) self.old_start = -1 self.old_end = -1 @@ -517,7 +519,15 @@ def disable_input(self): def update_attribute(self, attribute): point_to_display = np.array([attribute[DimensionReducedLabelEmbeddingSignal]]) - self.visualizer.update_grid(point_to_display) + self.visualizer.display_attribute_embedding(point_to_display) + + def update_nuggets(self, nuggets): + points_to_display: np.ndarray = np.array([nugget[DimensionReducedTextEmbeddingSignal] for nugget in nuggets]) + + if points_to_display.size == 0: + return + + self.visualizer.display_nugget_embedding(points_to_display) class SuggestionListItemWidget(CustomScrollableListItem): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 5cb1597d..8c277620 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -11,6 +11,9 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +RED = pg.mkColor('red') +BLUE = pg.mkColor('blue') +GREEN = pg.mkColor('green') def get_colors(distances, color_start='red', color_end='blue'): cmap = LinearSegmentedColormap.from_list("CustomMap", [color_start, color_end]) @@ -47,9 +50,15 @@ def __init__(self): add_grids(self.gl_widget) - def update_grid(self, points_to_display): + def display_attribute_embedding(self, attributes_embedding): + self.update_grid(attributes_embedding, RED) + + def display_nugget_embedding(self, nuggets_embeddings): + self.update_grid(nuggets_embeddings, GREEN) + + def update_grid(self, points_to_display, color): scatter = gl.GLScatterPlotItem(pos=points_to_display, - color=pg.glColor((0, 10)), + color=color, size=3, pxMode=True) self.gl_widget.addItem(scatter) diff --git a/wannadb_ui/wannadb_api.py b/wannadb_ui/wannadb_api.py index 7292422f..23fb1eb5 100644 --- a/wannadb_ui/wannadb_api.py +++ b/wannadb_ui/wannadb_api.py @@ -14,6 +14,7 @@ from wannadb.matching.custom_match_extraction import FaissSentenceSimilarityExtractor from wannadb.matching.distance import SignalsMeanDistance from wannadb.matching.matching import RankingBasedMatcher +from wannadb.preprocessing.dimension_reduction import PCAReducer from wannadb.preprocessing.embedding import BERTContextSentenceEmbedder, RelativePositionEmbedder, \ SBERTTextEmbedder, SBERTLabelEmbedder, SBERTDocumentSentenceEmbedder from wannadb.preprocessing.extraction import StanzaNERExtractor, SpacyNERExtractor @@ -125,7 +126,8 @@ def create_document_base(self, path, attribute_names, statistics): SBERTTextEmbedder("SBERTBertLargeNliMeanTokensResource"), BERTContextSentenceEmbedder("BertLargeCasedResource"), SBERTDocumentSentenceEmbedder("SBERTBertLargeNliMeanTokensResource"), - RelativePositionEmbedder() + RelativePositionEmbedder(), + PCAReducer() ]) # run preprocessing phase @@ -351,6 +353,7 @@ def interactive_table_population(self, document_base, statistics): ContextSentenceCacher(), SBERTLabelEmbedder("SBERTBertLargeNliMeanTokensResource"), SBERTDocumentSentenceEmbedder("SBERTBertLargeNliMeanTokensResource"), + PCAReducer(), RankingBasedMatcher( distance=SignalsMeanDistance( signal_identifiers=[ @@ -375,7 +378,8 @@ def interactive_table_population(self, document_base, statistics): SBERTLabelEmbedder("SBERTBertLargeNliMeanTokensResource"), SBERTTextEmbedder("SBERTBertLargeNliMeanTokensResource"), BERTContextSentenceEmbedder("BertLargeCasedResource"), - RelativePositionEmbedder() + RelativePositionEmbedder(), + PCAReducer() ] ), find_additional_nuggets=FaissSentenceSimilarityExtractor(num_similar_sentences=20, num_phrases_per_sentence=3), From 038f6cc0c696d490bbbe6be0014b21605f9099e2 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 22 Jun 2024 11:02:46 +0200 Subject: [PATCH 12/85] Add full screen 3D Grid View in separate window SQUASH: functional code --- main.py | 2 + .../interactive_matching.cpython-39.pyc | Bin 18565 -> 19360 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 7832 -> 10464 bytes .../__pycache__/wannadb_api.cpython-39.pyc | Bin 13446 -> 13550 bytes wannadb_ui/visualizations.py | 84 ++++++++++++++---- 5 files changed, 68 insertions(+), 18 deletions(-) diff --git a/main.py b/main.py index de42b137..d34272cf 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import logging import sys +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QApplication from wannadb.resources import ResourceManager @@ -14,6 +15,7 @@ with ResourceManager() as resource_manager: # set up PyQt application + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts) app = QApplication(sys.argv) window = MainWindow() diff --git a/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc b/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc index e2cbd7947aa373df6b1ef438a934a5e8296fda84..c3f0179886774577deacc03c104e7b87823f005e 100644 GIT binary patch delta 6127 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�nEB^-8=>kju^38## zUJ+k34R_|wfqt$(2k!cXBA#eoq2}|~6PS!caBS#CJQp5!AAQqB1mw9Z3!5nmRXS}P zBk*3PitP(SMaLDJ5-g@`r;0C@uKdZu^A^V-G4WmE)Xg8U-9is^EthV*7*sz%Djs!( znVL;6rf$$h_vZHS(Inlu$HgB4M|a-|rkrest_v^*I0QHhC<7b;SOG@?;{-uZT8n8B zJ(^5xpd0J}-R}#4!IC1@x}MDwFSTxJsH2%iMLI%Pr^Z96 zSW*uij;eG~avx-h%EenYAxq0BGQ6QgGL#!(le%hyu2^_Jw-OKiff%Z=vGFpy{j*gbQPJ-)Ef9Dr{GNN zPnrhIM$U0?KLhwa;Bi1c--asg6c;+CmP^sLxpJ;M@dcU~oqnkEQ|6F{e^4A*+PG{A z>zpPqtT8RYc!^JU^C(yb8cm?o;_|?(|&kxX)rZ@L2{d`JzY zb#Z8gulBnbpR>i+i1v&)yCTMRit4UCld3(VgbF3PB^F66)=Q}BEI7tg$B69&n?F@S| zj&8+58<9diH0t)ENG{FT`;^gV^{SaUL{;so{g}01+2@}q$k;LkwaSDu;~cKe*he6* zXA;EKwCmBHjP(RdKcf~+(0QLZ_UUlHRnTP|083P7Mi>N zM7ybMd5#>_?(=c7Tk?htPT$Q2cE|%9qF-d_ZjI-O>ICnsfB9foSLHjA@k`3cZ*d z^zqTBQ2eBKp1p*5=+*ki-ltZspkXRivNgjwBA6lHjaCm}j&KZI5wEOD_2$lA3C`^7 z-s{cAz?V#f`dSYJN_f6f;)j@e9w4tZX`F> zt5zoW>yT>r4#q|RZ-d`CtGhWz;3sI*FZ}({CaA?6y`G-|=<)3(cV?TSAg5rZ6gGAtPh1oE876d;)8dyew1Kq6eyQv#2 ziY5|N#m*G4qu{6&3pUp-MHKLS;qf5`J;`7aW{*ME|u)9TS zOIIYHijl_5|EOG7gE;@x7P>CJU|_R;i6F6E-PO%9bUl51AsK39)M@Q vd>3)#H8VX)<4EmgVYwLE)>!9Zot8>Vp~cCZR!_I5%QLB1={u>|JU#yfV49hG diff --git a/wannadb_ui/__pycache__/visualizations.cpython-39.pyc b/wannadb_ui/__pycache__/visualizations.cpython-39.pyc index 25e1d03a479dcabae5d1e20c4db2bc7ff634f54a..81e3f6e9b52fb26d63b7dc1e10ad057d1967e2b5 100644 GIT binary patch literal 10464 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 Hg|Gb&hRo}! delta 2827 zcmZ`*O>7)R7Vhfqnd$lU%y|44|HV#XGwTpS$S;XQ;`~flnS>}Igbt`_celr#boaEY zdlK85#Hw0nE8B0g3omM#`N-zKNtEIzl4!3*~F84q7#C$?8&+!epNrA zC)af;T?3usrTvVa@w0l?&*?coujj?Q>J|Jpy(r{_SMtkxS;$GR-LL2sA*Vdm@6bDh zta+V&m)=Ddaly3L?f2;0#6$+f+0;U3X%hy?AA!!ETZv z_AWV)&($WcxX!KC9Q%SC%5nv~xH{!E9X2j6lOp@KJP7!MJWD#*tTIM4_KtGp$s(*n z+Yveecm`q{R>N!12xlSlb7UWiAb@sq%KQ$K(my%O_t%}>TrqwAa~?gxt@$uOWJMN za-3eu*zjvBwrVFHs&^D&ub{mx!P3d|RfXcR09^thXR^@LK?|R4C)nM@@M1N~6HzmC zC>;VN*9^lA+}JRwtl4ZuRLtpe7G0t&02zkP!)STyX zSl3f^?-b?P?2j)GaUu!g(s>2NTeWZ zBqM8bj%Y+C^8b@6`z*Ipt|r*`>0L+AnCBr5hOr#admKu{+Y7Ve?!?)8U=+y!noNh_ zx+?6$zVxb993%lu|m$T$n>bNHN(&eY#5@ep265g+zdUZm5mK| zo^4qgXmNo3tLz+_01voM5vLHsH5gMSHLMwv#w$d``&kTn96S!QlkHy}6>$+DKnZ@h za>JQlA-d+ffnfugwp<33j&uqh+6}O9j9qN+VPR$D7%p5@#X?+lp#{Qpn z_#_5-4uB^tuMs&ki*gq3!-$$wcxb`t)lTYG*BQHK*;}g=`2X~mw7RA;!}7l zrnlY&Uhf_m7K6H2nf?jSkya|&+&CWD#vE}X;&JIAhYO-}qW!kNC*_@+y zaUq=HCE92PwqrxqqSy>9C;EBTYhZR}b$`zsIg&=V82~*e8p(|7*-$Gy;X;TlQRxDP z)?m*@?99b9Vqb3?-aCcfzd;CaI&D+)mf_l-Q%$wDmvC;v5#dl~Y|YR&*@51X%EnE% zNN@){XIZ0ncF&gb5nH0?F!E2$vCJJaXohdFLf`(GeQ5Cv!hQrK9zBbIg+;|p;bAE* zLf=9-fUp^JFAfF6k!ZhyCn^AFq>#}REum@JZmo?TL}IRf*f&NF3sOErFR>W*O?n97 zFalDP9zl2(0Sl5ILl|d60~0&2YD6aK36#X*VlQF`81MmmFc9s)Oi)qbXHXI*n|63W zw%RxNPdWJlP7kw>hYlX!FnUZNncBdg+~X=Rd^2oC5{n^+-bZi2p_A;yt|!^_@L1QH zXwTW-nsZD7D<%je2<2=Nq} z-$0cp(mBudX&>tPQJ2BOosXeGM$|*CkMK4^5}|<*BFrLCgg+rP5pE*fLYPCCM_6Fr z4Hx!^y4s{3nXgt_eQptL-ay!xrN3o|cTS#L0wqrYyPhNNNKZ}#=^i7<|1Wn%v z8_)tGI*9J#T;gv3fZd!ek^23|He7*_q5;q))FEG~;HTKY0~`vbibMY% zhU=Y@{sE^}5dMg;QAeBf{6=DrMkefEBpdFhMX_IG7cvG9DBe$iU3p;FRf<|D*8=*t zp*+N%9qmuhF_77{(cb(MAn*j_B%G&Jc5n2F8qfGar)UZtMIDqjwR>7`LsWxF zy}U&)nh3*U|szHhYtba8WAvLW(<>F3%pzhO5$=QvU&8UV<_ T>j1tAbOUbrDw%Y#RJ`^hT%&@} diff --git a/wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc b/wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc index eb0ff308621ee27d474e8b12ec27780310826e17..65e0e20256e109205835f42429f0366eb0fea58a 100644 GIT binary patch delta 3680 zcma)9TWlLy8J;s9Ut>FV6JL_%Qs?f(&8112(53~pVV9zIceU-3#qHLNeI~BAGj@7r zl3wH}u<4d^$&zrmio&KV@vulBsL`@K@KOl@56k5Zjd(*KRh5<(q(v2I`Tt|Lc0yL& zSpM>#>wN!z{`1f0n}zoZ0W}ctN$}@=cX{!htw1MvVQb{)Gen{iZPGi9u5_2to$fXc zqz@R0bi(LK_lPmS-fQ%w`;7i{zi0>a0b?-DTgZtNtZdOIjj8k$ku=Blw08IoNzXhZ z(N-F{A<>BDUU#HtDLEr0qkM%tXd<@n`4gXfL8HsL$_07MLAv?t&IMO5uws4ufs;8C zVD|E?>+O>R*g1&M4`3^~Qn6?|b9%|tSSR)bb87CQmZ8~WoVH{YO2x+2xFVdns1~WNWggF% zx$TAaNAM#)pi^L+flQVuzM8k1*!OwB3Z+m z^0jCApM1UH5wOZcS`8BEdi0jGovJ8JzadjoK=LiwcA1uHEt|q?Xu1u}G}XLjdzM%! z&!E8$!NjWfDgIx7jNIgrK(Xy0s|Jp0+2uU@G|s`mu^E0lFyAzZrW>K3KM2f{F5VX$ zJ{G}_C_+0z8$t{rj?jXz4nvfKq#m@5xGYG=%0$P}#>~jFNQdA9%rLovZXN{I~(#DhdcNm!X4z>m4Oyc z)~a4z$+FWp^I3poK%5xc!!6r+g%vE#cEhC+ZtbEce5=!QHn2ltRf;<(IV6_iMg?M0WG7-NBDTa|k9Uz7{*(BDrUl&RMTE2b zFY$?yU3BYAgS(kt=2LA)CPYYI!}U8z183;7!=}2TWh^xdmm30-Ggt0a-e~(O0Vhb; z)FF&IRFozBC)@JqIJ=BH?K%wKoL(@kT*+8E{vO}jzd+{r{r!V$=x;Lk;jW5E3(u;T z2Y1FSYDk#6cS_YeWFit|Pj3P$rI|;GBWw37TOFmED?@_wCNAGX*y#e}(B~k~|4sgK zR|y<=w`=Z+4h-T(AJu_sn%hpiA*OI49aVL>x%3*8Rfu1qn9o!Pg_F9}^M5q$V55VY z`Jc*abUqC>Pwu@;-X+>)| zuwA#OcDL)N_GMfvatN}qclR;aq3VW@&rl0hzS6T=^YGx4kMN-I5T0@UD8Jl4$T#~U z;uP6;p)VQQGZ#hm`X2Jy%gKD^|mB(YtEB8At?ldCQN?NDOAUikNpov8a1A0#T(j@Z>g z7vrU5jGW~2$un*E_7sxi2pn}?a$JANf0i6yTbC@K)s&a0_f0~58-zAdKMl|zZKjH) zSS}d%!#4n5CsnAEy7Dr0Z^)LLde(^*6xd8dV25Ci7xao3>@W?}eY9mmhBd7lzPtr|68)ICQ$KmNHIsmmOtNc9)%Hxh88`LCQQ5N)fQ-nyiU} ztZA#snsJadu}yLfl;x_YCT8ON?O{ENCv%oPg%LY}FpuylK09)kJY9J((nWev?XYJM zP9ijD8f=~dxW!Wk|K2x&P1K&YqL*@)Sf4Pjs0GV*Ef+;s51$^L9@@Es9V-;!wXEh) zDrR!3u4mQUrOaY+gMVXGtyaypJ+GPzA1L&X&_qH63F#wLPY_2@QZ|UCxgz(}KPFc?DAF5i?9xS$M~^V#w2S^~EbatNQzFg@8UfbCq= zOR81XRf3_o0$v;!)c2!>{B4C6au&Ob?M{So1O?$$1OtH~tRQ?H;adpb0eFgiAI%>i zyn*l|gm(b!KqiyZRnyFAx}M22y!4VdC56xpkl^Pg6686)Ix*Of?c~SU4IDdz@Fsxm z)=T+3M2|g>UDN#c6aP*nuc*bMO0%O&OalR(1N{s$Mri>=sEMh;@OO^*J<-g6eW*VY z!nNWY#j#e8pTB=-z!kxfDDRydb;Zz(^M%PIY2#m~L)eeh7_%5-~upVx6f*$gRXL`$;Pa5r6H|A%_TGnF{?66ZRlL delta 3580 zcma)9TWlO>6`p_gzU#f%-isZtFKc^kuQztqS?5CPq{^*9#I&eMNV2qD&(4fD+st12 z&)9ClriR);0%}S4D?m_zBk@3Ths^^}6m^^hay?dlQoEt8Wzc|12>p2$t`agR1xPUe#3soWGF`!&{2PLHX!ovgZepX3gm6O?`>ye%kU z)wN~I9ahK%Ar)b-l7BV6D(<(D5%zP(W#>4YPA8bdNgZP#j={=)IgKA@A43|a1@lzZRU+}i`QZ17)yg}vn-Ar8QHW5Z4~G-PWmE1%FhEwyD%~x8?*JM8FuDHd$Xcoy;{j zo$esJf^#^}Kqt={yE=GmfyZJSXYHX3jB(>$U0PS0R)9Hh%-fAW41R}z3%)e8co4(& zfKX%u*HE0#&>HU47De2|yM(68)pG5`d+g!RW%2|I#HYEpQh`r)6ps_`P}iK}Z~Fup&xgclKXKDGp^MB6;R zf_t`CHeS@Yi@CEct444Z&yY0k5?HO$BJ>Sq$$-W|KiW7sT_k6?c^f#B*Sg~%{Zj-; z5z|?!DDVV*dO>FI%_N>|Jqz6($6o~*58~x$kK?fRE~nz;94ngTf_(`A;934P{dJ4=~eoa{?|msu7qFn z79G&E*mxroQKHbu*p1%09dr(^izx%3a~gehC+J*tTb$-X?mN+}IBtjl0>=#Ms|vwJ5syB0Y)JPa&K}IK$NRFUi@)!2S_3 zkwwi@2%ksT+t|?apxb6w=l(I7!LQg#!b?$=uhR)GZb&7=bgosnD!~f#hi3NfV8%)n zczq=qJHYn-=nnh+ykr&Wfvy+Ei3jqQXAmXc_w=I_K=30BAYjPa8ln&mv8SX%2tEY< zXg!Y-&y*FEcnbSHR$b>gEVJDMVIMN4FCsi-e?G7;(n^+Arxa(kxw!KZq=C&pjBJ>vY8B{H@M++a4 zQTBQ^%1&gxksvC0NP1DSg2Hq)$ diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 8c277620..1ca3a781 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,3 +1,4 @@ +import copy from collections import OrderedDict import pyqtgraph as pg @@ -10,16 +11,16 @@ from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem RED = pg.mkColor('red') BLUE = pg.mkColor('blue') GREEN = pg.mkColor('green') + def get_colors(distances, color_start='red', color_end='blue'): cmap = LinearSegmentedColormap.from_list("CustomMap", [color_start, color_end]) - # Normalize the data for color mapping norm = plt.Normalize(min(distances), max(distances)) - # Generate the colors based on the data colors = [cmap(norm(value)) for value in distances] return colors @@ -37,31 +38,78 @@ def add_grids(widget): widget.addItem(grid_yz) -class EmbeddingVisualizerWidget(QWidget): +def update_grid(gl_widget, points_to_display, color): + scatter = GLScatterPlotItem(pos=points_to_display, color=color, size=3, pxMode=True) + print(f"type of points_to_display: {type(points_to_display)}") + gl_widget.addItem(scatter) + + + +class FullscreenWindow(QMainWindow): + def __init__(self, attribute_embeddings, nugget_embeddings): + super(FullscreenWindow, self).__init__() + + self.setWindowTitle("3D Grid Visualizer") + self.setGeometry(100, 100, 800, 600) + + central_widget = QWidget() + self.setCentralWidget(central_widget) + self.fullscreen_layout = QVBoxLayout() + central_widget.setLayout(self.fullscreen_layout) + + self.fullscreen_gl_widget = GLViewWidget() + self.fullscreen_layout.addWidget(self.fullscreen_gl_widget) + + add_grids(self.fullscreen_gl_widget) + self.copy_state(attribute_embeddings, nugget_embeddings, self.fullscreen_gl_widget) + def closeEvent(self, event): + self.parent().return_from_fullscreen() + event.accept() + + def copy_state(self, attribute_embeddings, nugget_embeddings, target_gl_widget): + update_grid(target_gl_widget, attribute_embeddings, RED) + update_grid(target_gl_widget, nugget_embeddings, GREEN) + +class EmbeddingVisualizerWidget(QWidget): def __init__(self): super(EmbeddingVisualizerWidget, self).__init__() - layout = QVBoxLayout() - self.setLayout(layout) + self.layout = QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.layout) + + self.gl_widget = GLViewWidget() + self.gl_widget.setMinimumHeight(200) # Set the initial height of the grid to 200 + self.layout.addWidget(self.gl_widget) - self.gl_widget = gl.GLViewWidget() - layout.addWidget(self.gl_widget) + self.fullscreen_button = QPushButton("Show 3D Grid in windowed fullscreen mode") + self.fullscreen_button.clicked.connect(self._show_fullscreen) + self.layout.addWidget(self.fullscreen_button) add_grids(self.gl_widget) - def display_attribute_embedding(self, attributes_embedding): - self.update_grid(attributes_embedding, RED) + self.fullscreen_window = None + self.attribute_embeddings = None + self.nugget_embeddings = None + + def _show_fullscreen(self): + if self.fullscreen_window is None: + self.fullscreen_window = FullscreenWindow(attribute_embeddings=self.attribute_embeddings, nugget_embeddings=self.nugget_embeddings) + self.fullscreen_window.show() + + def return_from_fullscreen(self): + self.fullscreen_window.close() + self.fullscreen_window = None + + def display_attribute_embedding(self, attribute_embeddings): + update_grid(self.gl_widget, attribute_embeddings, RED) + self.attribute_embeddings = attribute_embeddings # save for later use - def display_nugget_embedding(self, nuggets_embeddings): - self.update_grid(nuggets_embeddings, GREEN) + def display_nugget_embedding(self, nugget_embeddings): + update_grid(self.gl_widget, nugget_embeddings, GREEN) + self.nugget_embeddings = nugget_embeddings - def update_grid(self, points_to_display, color): - scatter = gl.GLScatterPlotItem(pos=points_to_display, - color=color, - size=3, - pxMode=True) - self.gl_widget.addItem(scatter) class BarChartVisualizerWidget(QWidget): @@ -133,7 +181,7 @@ def plot_bar_chart(self): self.distances = rounded_distances # todo after value is confirmed or value not in document, reinitialize data - #self.window.destroyed.connect(self.cleanup) + # self.window.destroyed.connect(self.cleanup) def on_pick(self, event): if isinstance(event.artist, Rectangle): From 0f472240a493e9c394c6d66bd627ef572eab40a1 Mon Sep 17 00:00:00 2001 From: Johanna Herbst Date: Sat, 22 Jun 2024 14:51:48 +0200 Subject: [PATCH 13/85] fixed scatterplot/barchart accumulation error Display only the relevant information nuggets on the scatter plot/bar chart for the currently selected document, without accumulating data from previously viewed documents (irrelevant information nuggets) --- wannadb_ui/interactive_matching.py | 15 +++++++++++++++ wannadb_ui/visualizations.py | 21 +++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 52037a1c..7b265a6e 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -506,6 +506,21 @@ def update_document(self, nugget): scroll_cursor.setPosition(nugget.start_char) self.text_edit.setTextCursor(scroll_cursor) self.text_edit.ensureCursorVisible() + # Clear bar chart data when updating document + self.clear_barchart_data() + + # Clear scatter plot data when updating document + self.clear_scatter_plot_data() + + # Update with new nuggets + self.update_nuggets(self.document.nuggets) + + def clear_barchart_data(self): + self.cosine_barchart.clear_data() + + def clear_scatter_plot_data(self): + self.scatter_plot_widget.clear_data() + def enable_input(self): self.match_button.setEnabled(True) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 1ca3a781..3b6b60c5 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -198,7 +198,7 @@ def on_pick(self, event): self.annotation.set_visible(True) self.bar_chart_canvas.draw_idle() - def cleanup(self): + def clear_data(self): self.data = [] self.bar = None @@ -216,10 +216,27 @@ def __init__(self, parent=None): self.scatter_plot_toolbar = None self.window = None self.annotation = None + self.texts = None + self.distances = None + self.y = None + self.scatter = None def append_data(self, data_tuple): self.data.append(data_tuple) + def clear_data(self): + self.data = [] + self.texts = None + self.distances = None + self.y = None + self.scatter = None + if self.window is not None: + self.window.close() + self.scatter_plot_canvas = None + self.scatter_plot_toolbar = None + self.window = None + self.annotation = None + def show_scatter_plot(self): if not self.data: return @@ -319,4 +336,4 @@ def on_pick(self, event): text = f"Distance: {self.distances[ind]:.3f}\nText: {self.texts[ind]}" self.annotation.set_text(text) self.annotation.set_visible(True) - self.scatter_plot_canvas.draw_idle() + self.scatter_plot_canvas.draw_idle() \ No newline at end of file From 8a608e691bd62dd7224b1f9927e12e1725fb8f30 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sun, 23 Jun 2024 16:59:11 +0200 Subject: [PATCH 14/85] Remove print debugs and fix error --- .../interactive_matching.cpython-39.pyc | Bin 19360 -> 19749 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 10464 -> 10621 bytes wannadb_ui/visualizations.py | 5 ----- 3 files changed, 5 deletions(-) diff --git a/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc b/wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc index c3f0179886774577deacc03c104e7b87823f005e..abbdc11841fa60c002f72c50ff29c604177bc2d4 100644 GIT binary patch delta 682 zcmZus-%FEG7(VZQF-PBA+BR*|=G4J5R+5?9bYnlXErOU~fraC1-z>-4=GnIjk* z`qLWcELl3TOn|WJ`d3Y}Bu8@kWl+ikPJj-4;9v2n&6#{wr0J_7&0H6$oR&mcRPFU< zIU~w)mZnSjVts&tKpFD*IxuB4X^dKbb%&^Y?_=01h%izFj=me8&hXxEhGF?MkIKXtN z@FD#uTUMt`hQ9&S@Q1|?9xO!OFy0>Z!Zs$No#0pUQ60brPRDwzo6JDfmSsvS+{(eB zhSgXB!sv?6!+Bhd-xNmou7r`E7JS!b#-m9AUm6804qt5N`BXhERtgerWZbKzz1-^q zhp+hXRI`4U(J}lrJZatG%>@pA4uc$a@qMyQ|ANsj>G@Fn`j3gKI7o#P{Z$M mGO+b`wF%58=Anc?6Sts@rQ|eR!R@36)|G~{3!rsb2L1q>f6a9O delta 393 zcmW-b-7AA}6vv;ljXkl=mL4;&N%NXD7uJT^Gh?$YB6nP{XkL=?Fp88r7b~UjqPX#r zhzset=O5rw<~@rm7h24XlHcQ;`gA(yd(QV9Y|+jp86tXp4#(o_H5N^cpBda%nnPb# zHHHm5maL*~mD6wodHr0iMY3VsTPA0$b|VeTRgNK*yzeX`D#y9&7#q#A6h%(Ugle%2 z5nBP)TP(O}ol^Z08r+AZ#9k#Y$|8j)Foq=1JGXt67?OEpOxV`H=iBeh@t%Z8Vb^pY5lt-uKngGp>A< zR41o>8lroAchn1y?7*FRc21IFic_(I2RJ&X$&Q20IEiTV&yx>F{yj<}94MhT>^9|R zrI^r7D=V4NaY4lU3qGNBpj R##p$DlJZ(OL28rH`3FWud<6gi diff --git a/wannadb_ui/__pycache__/visualizations.cpython-39.pyc b/wannadb_ui/__pycache__/visualizations.cpython-39.pyc index 81e3f6e9b52fb26d63b7dc1e10ad057d1967e2b5..863279abffd4762b7131730032014f24f48a7ef8 100644 GIT binary patch delta 2872 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 20: annotation_x = patch.get_x() + patch.get_width() / 2 annotation_y = patch.get_height() / 2 From 7d91b9596dc1902a0d82d0148f5bb8a14782b73c Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 24 Jun 2024 12:49:04 +0200 Subject: [PATCH 15/85] highlight currently selected nugget in visualizer --- wannadb_ui/interactive_matching.py | 16 ++---- wannadb_ui/visualizations.py | 92 ++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 7b265a6e..6d101471 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -291,7 +291,6 @@ def __init__(self, interactive_matching_widget): self.text_edit.selectionChanged.connect(self._handle_selection_changed) self.text_edit.setText("") - # last custom selection values self.custom_start = 0 self.custom_end = 0 @@ -307,7 +306,6 @@ def __init__(self, interactive_matching_widget): self.suggestion_list.setFixedHeight(60) self.layout.addWidget(self.suggestion_list) - self.upper_buttons_widget = QWidget() self.upper_buttons_widget_layout = QHBoxLayout(self.upper_buttons_widget) self.upper_buttons_widget_layout.setContentsMargins(0, 0, 0, 0) @@ -427,6 +425,8 @@ def _highlight_current_nugget(self): ) self.text_edit.setText("") self.text_edit.textCursor().insertHtml(formatted_text) + + self.visualizer.highlight_nugget(self.current_nugget) else: self.text_edit.setText("") self.text_edit.textCursor().insertHtml(self.base_formatted_text) @@ -512,16 +512,12 @@ def update_document(self, nugget): # Clear scatter plot data when updating document self.clear_scatter_plot_data() - # Update with new nuggets - self.update_nuggets(self.document.nuggets) - def clear_barchart_data(self): self.cosine_barchart.clear_data() def clear_scatter_plot_data(self): self.scatter_plot_widget.clear_data() - def enable_input(self): self.match_button.setEnabled(True) self.no_match_button.setEnabled(True) @@ -537,12 +533,11 @@ def update_attribute(self, attribute): self.visualizer.display_attribute_embedding(point_to_display) def update_nuggets(self, nuggets): - points_to_display: np.ndarray = np.array([nugget[DimensionReducedTextEmbeddingSignal] for nugget in nuggets]) - - if points_to_display.size == 0: + if len(nuggets) == 0: return - self.visualizer.display_nugget_embedding(points_to_display) + self.visualizer.reset() + self.visualizer.display_nugget_embedding(nuggets) class SuggestionListItemWidget(CustomScrollableListItem): @@ -571,7 +566,6 @@ def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: self.suggestion_list_widget.interactive_matching_widget.document_widget._highlight_current_nugget() self.suggestion_list_widget.interactive_matching_widget.document_widget.custom_selection_item_widget.hide() - def update_item(self, item, params=None): self.nugget = item sanitized_text, distance = self.get_nugget_data() diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 31fe68d1..7b3933ce 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,4 +1,5 @@ import copy +import logging from collections import OrderedDict import pyqtgraph as pg @@ -13,6 +14,10 @@ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem +from wannadb.data.signals import DimensionReducedTextEmbeddingSignal + +logger: logging.Logger = logging.getLogger(__name__) + RED = pg.mkColor('red') BLUE = pg.mkColor('blue') GREEN = pg.mkColor('green') @@ -38,15 +43,18 @@ def add_grids(widget): widget.addItem(grid_yz) -def update_grid(gl_widget, points_to_display, color): +def update_grid(gl_widget, points_to_display, color) -> GLScatterPlotItem: scatter = GLScatterPlotItem(pos=points_to_display, color=color, size=3, pxMode=True) gl_widget.addItem(scatter) + return scatter +class EmbeddingVisualizerWindow(QMainWindow): + def __init__(self, attribute_embedding, nuggets, currently_highlighted_nugget): + super(EmbeddingVisualizerWindow, self).__init__() -class FullscreenWindow(QMainWindow): - def __init__(self, attribute_embeddings, nugget_embeddings): - super(FullscreenWindow, self).__init__() + self.nugget_to_scatter = {} + self.currently_highlighted_nugget = None self.setWindowTitle("3D Grid Visualizer") self.setGeometry(100, 100, 800, 600) @@ -60,14 +68,36 @@ def __init__(self, attribute_embeddings, nugget_embeddings): self.fullscreen_layout.addWidget(self.fullscreen_gl_widget) add_grids(self.fullscreen_gl_widget) - self.copy_state(attribute_embeddings, nugget_embeddings, self.fullscreen_gl_widget) + self.copy_state(attribute_embedding, nuggets) + + if currently_highlighted_nugget is not None: + self.highlight_nugget(currently_highlighted_nugget) def closeEvent(self, event): event.accept() - def copy_state(self, attribute_embeddings, nugget_embeddings, target_gl_widget): - update_grid(target_gl_widget, attribute_embeddings, RED) - update_grid(target_gl_widget, nugget_embeddings, GREEN) + def copy_state(self, attribute_embeddings, nuggets): + update_grid(self.fullscreen_gl_widget, attribute_embeddings, RED) + + for nugget in nuggets: + nugget_embedding: np.ndarray = np.array([nugget[DimensionReducedTextEmbeddingSignal]]) + scatter = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN) + self.nugget_to_scatter[nugget] = scatter + + def highlight_nugget(self, nugget): + scatter_to_highlight = self.nugget_to_scatter[nugget] + + if scatter_to_highlight is None: + logger.warning("Couldn't find nugget to highlight") + return + + if self.currently_highlighted_nugget is not None: + currently_highlighted_scatter = self.nugget_to_scatter[self.currently_highlighted_nugget] + currently_highlighted_scatter.setData(color=GREEN, size=3) + + scatter_to_highlight.setData(color=BLUE, size=10) + self.currently_highlighted_nugget = nugget + class EmbeddingVisualizerWidget(QWidget): def __init__(self): @@ -82,21 +112,24 @@ def __init__(self): self.layout.addWidget(self.gl_widget) self.fullscreen_button = QPushButton("Show 3D Grid in windowed fullscreen mode") - self.fullscreen_button.clicked.connect(self._show_fullscreen) + self.fullscreen_button.clicked.connect(self._show_embedding_visualizer_window) self.layout.addWidget(self.fullscreen_button) add_grids(self.gl_widget) self.fullscreen_window = None self.attribute_embeddings = None - self.nugget_embeddings = None + self.nugget_to_scatter = {} + self.currently_highlighted_nugget = None - def _show_fullscreen(self): + def _show_embedding_visualizer_window(self): if self.fullscreen_window is None: - self.fullscreen_window = FullscreenWindow(attribute_embeddings=self.attribute_embeddings, nugget_embeddings=self.nugget_embeddings) + self.fullscreen_window = EmbeddingVisualizerWindow(attribute_embedding=self.attribute_embeddings, + nuggets=self.nugget_to_scatter.keys(), + currently_highlighted_nugget=self.currently_highlighted_nugget) self.fullscreen_window.show() - def return_from_fullscreen(self): + def return_from_embedding_visualizer_window(self): self.fullscreen_window.close() self.fullscreen_window = None @@ -104,10 +137,35 @@ def display_attribute_embedding(self, attribute_embeddings): update_grid(self.gl_widget, attribute_embeddings, RED) self.attribute_embeddings = attribute_embeddings # save for later use - def display_nugget_embedding(self, nugget_embeddings): - update_grid(self.gl_widget, nugget_embeddings, GREEN) - self.nugget_embeddings = nugget_embeddings + def display_nugget_embedding(self, nuggets): + for nugget in nuggets: + nugget_embedding: np.ndarray = np.array([nugget[DimensionReducedTextEmbeddingSignal]]) + scatter = update_grid(self.gl_widget, nugget_embedding, GREEN) + self.nugget_to_scatter[nugget] = scatter + + def highlight_nugget(self, nugget): + scatter_to_highlight = self.nugget_to_scatter[nugget] + + if scatter_to_highlight is None: + logger.warning("Couldn't find nugget to highlight") + return + + if self.currently_highlighted_nugget is not None: + currently_highlighted_scatter = self.nugget_to_scatter[self.currently_highlighted_nugget] + currently_highlighted_scatter.setData(color=GREEN, size=3) + scatter_to_highlight.setData(color=BLUE, size=10) + self.currently_highlighted_nugget = nugget + + if self.fullscreen_window is not None: + self.fullscreen_window.highlight_nugget(nugget) + + def reset(self): + [self.gl_widget.removeItem(scatter) for scatter in self.nugget_to_scatter.values()] + + self.fullscreen_window = None + self.nugget_to_scatter = {} + self.currently_highlighted_nugget = None class BarChartVisualizerWidget(QWidget): @@ -331,4 +389,4 @@ def on_pick(self, event): text = f"Distance: {self.distances[ind]:.3f}\nText: {self.texts[ind]}" self.annotation.set_text(text) self.annotation.set_visible(True) - self.scatter_plot_canvas.draw_idle() \ No newline at end of file + self.scatter_plot_canvas.draw_idle() From 258af402d5a7e36577f67a9bdbd769d2e34d219d Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 24 Jun 2024 12:53:35 +0200 Subject: [PATCH 16/85] remove pycache folder --- wannadb_ui/__pycache__/__init__.cpython-39.pyc | Bin 181 -> 0 bytes wannadb_ui/__pycache__/common.cpython-39.pyc | Bin 6609 -> 0 bytes .../__pycache__/document_base.cpython-39.pyc | Bin 16825 -> 0 bytes .../interactive_matching.cpython-39.pyc | Bin 19749 -> 0 bytes .../__pycache__/main_window.cpython-39.pyc | Bin 19986 -> 0 bytes .../__pycache__/start_menu.cpython-39.pyc | Bin 3167 -> 0 bytes .../__pycache__/visualizations.cpython-39.pyc | Bin 10621 -> 0 bytes .../__pycache__/wannadb_api.cpython-39.pyc | Bin 13550 -> 0 bytes 8 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 wannadb_ui/__pycache__/__init__.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/common.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/document_base.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/interactive_matching.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/main_window.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/start_menu.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/visualizations.cpython-39.pyc delete mode 100644 wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc diff --git a/wannadb_ui/__pycache__/__init__.cpython-39.pyc b/wannadb_ui/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index 8a57ea5d29beba77e81187e7ec3719db89339539..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 181 zcmYe~<>g`kf?0Q?(m?cM5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;x_ienx(7s(xx- zYNEbNesXDUYF+wk$yl?Vs=Spc4@ADPGXXgiEepfUS48Kl5SaM tv2JlmX-cI&R3yGMQ$IdFGcU6wK3=b&@)n0pZhlH>PO2Tq&d)&10063TFL3|> diff --git a/wannadb_ui/__pycache__/common.cpython-39.pyc b/wannadb_ui/__pycache__/common.cpython-39.pyc deleted file mode 100644 index 24442a79c742f1b4bc872b427478a6ba9a1c2391..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6609 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 diff --git a/wannadb_ui/__pycache__/document_base.cpython-39.pyc b/wannadb_ui/__pycache__/document_base.cpython-39.pyc deleted file mode 100644 index f76fe3c315bf5b375240751ceedc4d1bca7001ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16825 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%0Dx(%F`XkF8^JCanle8U2gByhkkP&pNP^#&67&Zn| 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 eB7Y~ZOmKv+Q0nl|@P7l^s<8V2 diff --git a/wannadb_ui/__pycache__/start_menu.cpython-39.pyc b/wannadb_ui/__pycache__/start_menu.cpython-39.pyc deleted file mode 100644 index fe98c086fdcfb5a29c99071d2f6ef4cb5ce22eec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3167 zcmbVOTT|Oc6qe+>d;uKVl7s{&DG7c)%en%;-~KRqOI1br z6C2528XDig!xW1zX zjiMaOI3>4il--I^amS1?RiVmYoKBpmM)ie4vLyFbAvsz(P8m0dx~bIjXW&;^t81aQ zA;QeYrhP=~DpbDh`d&Q~7B^ll`3G(5&<{kI-Dq1K>Okjuz_*tIA$$)SKVBem8(TK% zQ&CTadi&|pO51$?-P)^gV%4&}E!!jh{xjbb)DvN4^M|FED^HhKzL$_L(n$VNaf2^F zg(`+h6hnKb^i-k}?QPyjkrYY4Rg5&rkSx42@07G+WMN6UaBNdpOsrBbh}d+sgk`K} z;Xw!dcW4PksBe^GZJ@mY<0qP`yio=zqMfK%+er^H;7a~a(s56gq;XW_jsaojMFDX6 zW3XcCP}8r?&<9zPIk#6tw47feSvji=J>@|~jKvszfS7W^$GDh)o^gq;LalnuM-_bJ z;`!CV4Kca%(ay)0?2Su%pGeMAU}I`%x+#@ock{(K=D0x1Bo(Hh1PcLB}NK6#DSsA%miJk-W+_hEBU5ngo?4u-S z&BLtuMC9gV(_kL6OpT5iCkpwp|lr8t^qb0iO4ku7m;fWFmmPd z$ThA-u5li@&(NmigOO{*tExzz3(&KWh+JK+Y5_4sffOHGJG!w&ov!cFn(%8|mgiZ^ zOZV(wRYhU&C}(%EKfSzZ$lsAuN+P(zB_*H5ccyJnizI-Uuq=Fe;5_}Dp-U>GR9E0jQj63%Y)7~wIB{*4!ArA zp=BkUG5_8f`^?&dbq@(;{u??bRstta9lP&gJ~5QluzcQr?pXb>Ae(L46D)_rw__s1 zjEbTG+mH811v%RXl)R*Ne_Mp*VLJ*vE(z}>hBszdj9YkJF;wy}*LCdfE+tU$Jr7bZ zEV^*%z|!#63Fr8>e{~xq8-KWZQ&@c0xHXcZgV!Fz<7C`)AVEj&=g6TbR>t&*S?Akj z+#4}^d22ohiT7g^vpBY+9N;-H@eo7{pBlkIE*m}#(wsUyHVex?Rl|a5b{&gz(>zuF z`nk0Y8q}g5wOY%uwD8swv2AWrIM0Hz);hCxg}ocNEim`RN@;k9h2`FJbW4okZomv9}$tXYr{H<_Nlh^s-9vf%u*{>JgehS z!semlRAqH|G5pqv3e@DVL?s5~XfF`!(8DqL6(l%)m0%M3|4FhY*dMaQL$+-53!3b6 z7`;leOd?3c@)aO1Sl+qH@)8!l)Ie)d_(5xR({z0jI9M;4<}ZQeL?bc_*dz4(B@|&f z&c7~o9Mep&X&?mSCS@F@oTFeub}Z%7vadcF<{ZBda@`M0>xUcSNpk~qoKdM>k&YCH z{VxJL(webhPo%<}p-f6GmZ~OYTq=GzagoVQxSL&2E7S~D!~&%_EWo#M8yLV{^BCJ% jq!V8}iKWOqkSUP=DvHY2m}?wu1Ky${L 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 NiA$B{N|n+F{|y38`T+m{ diff --git a/wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc b/wannadb_ui/__pycache__/wannadb_api.cpython-39.pyc deleted file mode 100644 index 65e0e20256e109205835f42429f0366eb0fea58a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13550 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 From 26472ac7388bdfa65badf2e728311e9b7f13d7d6 Mon Sep 17 00:00:00 2001 From: Dongtaes <62911712+Dongtaes@users.noreply.github.com> Date: Tue, 25 Jun 2024 12:49:38 +0200 Subject: [PATCH 17/85] rm pycache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3d509736..df7cd50d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ .json .pdf .bson +/evaluation/datasets/aviation/documents +/evaluation/datasets/nobel/documents +/evaluation/results/ From 0490a4f06d75d8b71392317ce3a90681a566ada6 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Wed, 26 Jun 2024 00:20:49 +0200 Subject: [PATCH 18/85] implement possibility to use T-SNE dimension reduction --- wannadb/data/signals.py | 20 ++++++-- wannadb/preprocessing/dimension_reduction.py | 51 +++++++++++++++++--- wannadb_ui/interactive_matching.py | 5 +- wannadb_ui/visualizations.py | 6 +-- wannadb_ui/wannadb_api.py | 9 ++-- 5 files changed, 74 insertions(+), 17 deletions(-) diff --git a/wannadb/data/signals.py b/wannadb/data/signals.py index 02836170..7a1a394e 100644 --- a/wannadb/data/signals.py +++ b/wannadb/data/signals.py @@ -357,12 +357,19 @@ class LabelEmbeddingSignal(BaseNumpyArraySignal): @register_signal -class DimensionReducedLabelEmbeddingSignal(BaseNumpyArraySignal): +class PCADimensionReducedLabelEmbeddingSignal(BaseNumpyArraySignal): """Embedding of the nugget's label or attribute's name reduced to 3 dimensions.""" identifier: str = "DimensionReducedLabelEmbeddingSignal" do_serialize: bool = True +@register_signal +class TSNEDimensionReducedLabelEmbeddingSignal(BaseNumpyArraySignal): + """Embedding of the nugget's label or attribute's name reduced to 3 dimensions.""" + identifier: str = "TSNEDimensionReducedLabelEmbeddingSignal" + do_serialize: bool = True + + @register_signal class TextEmbeddingSignal(BaseNumpyArraySignal): """Embedding of the nugget's text.""" @@ -371,9 +378,16 @@ class TextEmbeddingSignal(BaseNumpyArraySignal): @register_signal -class DimensionReducedTextEmbeddingSignal(BaseNumpyArraySignal): +class PCADimensionReducedTextEmbeddingSignal(BaseNumpyArraySignal): + """Embedding of the nugget's text reduced to 3 dimensions.""" + identifier: str = "PCADimensionReducedTextEmbeddingSignal" + do_serialize: bool = True + + +@register_signal +class TSNEDimensionReducedTextEmbeddingSignal(BaseNumpyArraySignal): """Embedding of the nugget's text reduced to 3 dimensions.""" - identifier: str = "DimensionReducedTextEmbeddingSignal" + identifier: str = "TSNEDimensionReducedTextEmbeddingSignal" do_serialize: bool = True diff --git a/wannadb/preprocessing/dimension_reduction.py b/wannadb/preprocessing/dimension_reduction.py index 86567961..4ca20709 100644 --- a/wannadb/preprocessing/dimension_reduction.py +++ b/wannadb/preprocessing/dimension_reduction.py @@ -4,11 +4,13 @@ from numpy import ndarray from sklearn.decomposition import PCA +from sklearn.manifold import TSNE from wannadb.configuration import BasePipelineElement, register_configurable_element from wannadb.data.data import DocumentBase -from wannadb.data.signals import LabelEmbeddingSignal, TextEmbeddingSignal, DimensionReducedLabelEmbeddingSignal, \ - DimensionReducedTextEmbeddingSignal +from wannadb.data.signals import LabelEmbeddingSignal, TextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ + PCADimensionReducedTextEmbeddingSignal, TSNEDimensionReducedLabelEmbeddingSignal, \ + TSNEDimensionReducedTextEmbeddingSignal from wannadb.interaction import BaseInteractionCallback from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback @@ -58,11 +60,11 @@ def __call__( for idx, embedding in enumerate(dimension_reduced_embeddings): if idx < len(attribute_embeddings): - document_base.attributes[idx][DimensionReducedLabelEmbeddingSignal] = ( - DimensionReducedLabelEmbeddingSignal(embedding)) + document_base.attributes[idx][PCADimensionReducedLabelEmbeddingSignal] = ( + PCADimensionReducedLabelEmbeddingSignal(embedding)) else: - document_base.nuggets[idx - len(attribute_embeddings)][DimensionReducedTextEmbeddingSignal] = ( - DimensionReducedLabelEmbeddingSignal(embedding)) + document_base.nuggets[idx - len(attribute_embeddings)][PCADimensionReducedTextEmbeddingSignal] = ( + PCADimensionReducedTextEmbeddingSignal(embedding)) def reduce_dimensions(self, data) -> ndarray: self.pca.fit(data) @@ -76,3 +78,40 @@ def to_config(self) -> Dict[str, Any]: @classmethod def from_config(cls, config: Dict[str, Any]) -> "DimensionReducer": return cls() + + +@register_configurable_element +class TSNEReducer(DimensionReducer): + identifier: str = "TSNEReducer" + + def __init__(self): + super().__init__() + self.tsne = TSNE(n_components=3, n_iter=300) + + def _call(self, document_base: DocumentBase, interaction_callback: BaseInteractionCallback, + status_callback: BaseStatusCallback, statistics: Statistics) -> None: + attribute_embeddings = [attribute[LabelEmbeddingSignal] for attribute in document_base.attributes] + nugget_embeddings = [nugget[TextEmbeddingSignal] for nugget in document_base.nuggets] + all_embeddings = attribute_embeddings + nugget_embeddings + + dimension_reduced_embeddings = self.reduce_dimensions(all_embeddings) + + for idx, embedding in enumerate(dimension_reduced_embeddings): + if idx < len(attribute_embeddings): + document_base.attributes[idx][TSNEDimensionReducedLabelEmbeddingSignal] = ( + TSNEDimensionReducedLabelEmbeddingSignal(embedding)) + else: + document_base.nuggets[idx - len(attribute_embeddings)][TSNEDimensionReducedTextEmbeddingSignal] = ( + TSNEDimensionReducedTextEmbeddingSignal(embedding)) + + def reduce_dimensions(self, data) -> ndarray: + return self.tsne.fit_transform(data) + + def to_config(self) -> Dict[str, Any]: + return { + "identifier": self.identifier + } + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> "DimensionReducer": + return cls() diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 6d101471..7b463df0 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -7,7 +7,8 @@ from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ - DimensionReducedLabelEmbeddingSignal, DimensionReducedTextEmbeddingSignal + PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ + TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget @@ -529,7 +530,7 @@ def disable_input(self): self.suggestion_list.disable_input() def update_attribute(self, attribute): - point_to_display = np.array([attribute[DimensionReducedLabelEmbeddingSignal]]) + point_to_display = np.array([attribute[PCADimensionReducedLabelEmbeddingSignal]]) self.visualizer.display_attribute_embedding(point_to_display) def update_nuggets(self, nuggets): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 7b3933ce..b5e700d0 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -14,7 +14,7 @@ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem -from wannadb.data.signals import DimensionReducedTextEmbeddingSignal +from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, TSNEDimensionReducedTextEmbeddingSignal logger: logging.Logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def copy_state(self, attribute_embeddings, nuggets): update_grid(self.fullscreen_gl_widget, attribute_embeddings, RED) for nugget in nuggets: - nugget_embedding: np.ndarray = np.array([nugget[DimensionReducedTextEmbeddingSignal]]) + nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) scatter = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN) self.nugget_to_scatter[nugget] = scatter @@ -139,7 +139,7 @@ def display_attribute_embedding(self, attribute_embeddings): def display_nugget_embedding(self, nuggets): for nugget in nuggets: - nugget_embedding: np.ndarray = np.array([nugget[DimensionReducedTextEmbeddingSignal]]) + nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) scatter = update_grid(self.gl_widget, nugget_embedding, GREEN) self.nugget_to_scatter[nugget] = scatter diff --git a/wannadb_ui/wannadb_api.py b/wannadb_ui/wannadb_api.py index 23fb1eb5..03d7a081 100644 --- a/wannadb_ui/wannadb_api.py +++ b/wannadb_ui/wannadb_api.py @@ -14,7 +14,7 @@ from wannadb.matching.custom_match_extraction import FaissSentenceSimilarityExtractor from wannadb.matching.distance import SignalsMeanDistance from wannadb.matching.matching import RankingBasedMatcher -from wannadb.preprocessing.dimension_reduction import PCAReducer +from wannadb.preprocessing.dimension_reduction import PCAReducer, TSNEReducer from wannadb.preprocessing.embedding import BERTContextSentenceEmbedder, RelativePositionEmbedder, \ SBERTTextEmbedder, SBERTLabelEmbedder, SBERTDocumentSentenceEmbedder from wannadb.preprocessing.extraction import StanzaNERExtractor, SpacyNERExtractor @@ -127,7 +127,8 @@ def create_document_base(self, path, attribute_names, statistics): BERTContextSentenceEmbedder("BertLargeCasedResource"), SBERTDocumentSentenceEmbedder("SBERTBertLargeNliMeanTokensResource"), RelativePositionEmbedder(), - PCAReducer() + PCAReducer(), + TSNEReducer() ]) # run preprocessing phase @@ -354,6 +355,7 @@ def interactive_table_population(self, document_base, statistics): SBERTLabelEmbedder("SBERTBertLargeNliMeanTokensResource"), SBERTDocumentSentenceEmbedder("SBERTBertLargeNliMeanTokensResource"), PCAReducer(), + TSNEReducer(), RankingBasedMatcher( distance=SignalsMeanDistance( signal_identifiers=[ @@ -379,7 +381,8 @@ def interactive_table_population(self, document_base, statistics): SBERTTextEmbedder("SBERTBertLargeNliMeanTokensResource"), BERTContextSentenceEmbedder("BertLargeCasedResource"), RelativePositionEmbedder(), - PCAReducer() + PCAReducer(), + TSNEReducer() ] ), find_additional_nuggets=FaissSentenceSimilarityExtractor(num_similar_sentences=20, num_phrases_per_sentence=3), From 90c205a9ab66d36af01526724fa188818d3f5262 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 1 Jul 2024 00:07:48 +0200 Subject: [PATCH 19/85] add static annotation indicating corresponding nugget to items in 3D grid --- wannadb_ui/interactive_matching.py | 3 +- wannadb_ui/visualizations.py | 75 ++++++++++++++++++------------ 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 7b463df0..f2218f74 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -530,8 +530,7 @@ def disable_input(self): self.suggestion_list.disable_input() def update_attribute(self, attribute): - point_to_display = np.array([attribute[PCADimensionReducedLabelEmbeddingSignal]]) - self.visualizer.display_attribute_embedding(point_to_display) + self.visualizer.display_attribute_embedding(attribute) def update_nuggets(self, nuggets): if len(nuggets) == 0: diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index b5e700d0..aaa138f7 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,10 +1,9 @@ -import copy import logging -from collections import OrderedDict import pyqtgraph as pg import pyqtgraph.opengl as gl import numpy as np +from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel from matplotlib import pyplot as plt from matplotlib.colors import LinearSegmentedColormap @@ -12,15 +11,18 @@ from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar -from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem +from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem, GLTextItem -from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, TSNEDimensionReducedTextEmbeddingSignal +from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, TSNEDimensionReducedTextEmbeddingSignal, \ + PCADimensionReducedLabelEmbeddingSignal logger: logging.Logger = logging.getLogger(__name__) RED = pg.mkColor('red') BLUE = pg.mkColor('blue') GREEN = pg.mkColor('green') +WHITE = pg.mkColor('white') +EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) def get_colors(distances, color_start='red', color_end='blue'): @@ -43,17 +45,22 @@ def add_grids(widget): widget.addItem(grid_yz) -def update_grid(gl_widget, points_to_display, color) -> GLScatterPlotItem: +def update_grid(gl_widget, points_to_display, color, annotation_text) -> (GLScatterPlotItem, GLTextItem): scatter = GLScatterPlotItem(pos=points_to_display, color=color, size=3, pxMode=True) + annotation = GLTextItem(pos=[points_to_display[0][0], points_to_display[0][1], points_to_display[0][2]], + color=WHITE, + text=annotation_text, + font=EMBEDDING_ANNOTATION_FONT) gl_widget.addItem(scatter) - return scatter + gl_widget.addItem(annotation) + return scatter, annotation class EmbeddingVisualizerWindow(QMainWindow): - def __init__(self, attribute_embedding, nuggets, currently_highlighted_nugget): + def __init__(self, attribute, nuggets, currently_highlighted_nugget): super(EmbeddingVisualizerWindow, self).__init__() - self.nugget_to_scatter = {} + self.nugget_to_displayed_items = {} self.currently_highlighted_nugget = None self.setWindowTitle("3D Grid Visualizer") @@ -68,7 +75,7 @@ def __init__(self, attribute_embedding, nuggets, currently_highlighted_nugget): self.fullscreen_layout.addWidget(self.fullscreen_gl_widget) add_grids(self.fullscreen_gl_widget) - self.copy_state(attribute_embedding, nuggets) + self.copy_state(attribute, nuggets) if currently_highlighted_nugget is not None: self.highlight_nugget(currently_highlighted_nugget) @@ -76,23 +83,27 @@ def __init__(self, attribute_embedding, nuggets, currently_highlighted_nugget): def closeEvent(self, event): event.accept() - def copy_state(self, attribute_embeddings, nuggets): - update_grid(self.fullscreen_gl_widget, attribute_embeddings, RED) + def copy_state(self, attribute, nuggets): + + update_grid(self.fullscreen_gl_widget, + [attribute[PCADimensionReducedLabelEmbeddingSignal]], + RED, + attribute.name) for nugget in nuggets: nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) - scatter = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN) - self.nugget_to_scatter[nugget] = scatter + scatter, annotation = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN, nugget.text) + self.nugget_to_displayed_items[nugget] = (scatter, annotation) def highlight_nugget(self, nugget): - scatter_to_highlight = self.nugget_to_scatter[nugget] + scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] if scatter_to_highlight is None: logger.warning("Couldn't find nugget to highlight") return if self.currently_highlighted_nugget is not None: - currently_highlighted_scatter = self.nugget_to_scatter[self.currently_highlighted_nugget] + currently_highlighted_scatter, _ = self.nugget_to_displayed_items[self.currently_highlighted_nugget] currently_highlighted_scatter.setData(color=GREEN, size=3) scatter_to_highlight.setData(color=BLUE, size=10) @@ -118,14 +129,14 @@ def __init__(self): add_grids(self.gl_widget) self.fullscreen_window = None - self.attribute_embeddings = None - self.nugget_to_scatter = {} + self.attribute = None + self.nugget_to_displayed_items = {} self.currently_highlighted_nugget = None def _show_embedding_visualizer_window(self): if self.fullscreen_window is None: - self.fullscreen_window = EmbeddingVisualizerWindow(attribute_embedding=self.attribute_embeddings, - nuggets=self.nugget_to_scatter.keys(), + self.fullscreen_window = EmbeddingVisualizerWindow(attribute=self.attribute, + nuggets=self.nugget_to_displayed_items.keys(), currently_highlighted_nugget=self.currently_highlighted_nugget) self.fullscreen_window.show() @@ -133,25 +144,29 @@ def return_from_embedding_visualizer_window(self): self.fullscreen_window.close() self.fullscreen_window = None - def display_attribute_embedding(self, attribute_embeddings): - update_grid(self.gl_widget, attribute_embeddings, RED) - self.attribute_embeddings = attribute_embeddings # save for later use + def display_attribute_embedding(self, attribute): + attribute_embedding = np.array([attribute[PCADimensionReducedLabelEmbeddingSignal]]) + update_grid(self.gl_widget, attribute_embedding, RED, attribute.name) + self.attribute = attribute # save for later use def display_nugget_embedding(self, nuggets): for nugget in nuggets: - nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) - scatter = update_grid(self.gl_widget, nugget_embedding, GREEN) - self.nugget_to_scatter[nugget] = scatter + self._add_nugget_embedding(nugget) + + def _add_nugget_embedding(self, nugget): + nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) + scatter, annotation = update_grid(self.gl_widget, nugget_embedding, GREEN, nugget.text) + self.nugget_to_displayed_items[nugget] = (scatter, annotation) def highlight_nugget(self, nugget): - scatter_to_highlight = self.nugget_to_scatter[nugget] + scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] if scatter_to_highlight is None: logger.warning("Couldn't find nugget to highlight") return if self.currently_highlighted_nugget is not None: - currently_highlighted_scatter = self.nugget_to_scatter[self.currently_highlighted_nugget] + currently_highlighted_scatter, _ = self.nugget_to_displayed_items[self.currently_highlighted_nugget] currently_highlighted_scatter.setData(color=GREEN, size=3) scatter_to_highlight.setData(color=BLUE, size=10) @@ -161,10 +176,12 @@ def highlight_nugget(self, nugget): self.fullscreen_window.highlight_nugget(nugget) def reset(self): - [self.gl_widget.removeItem(scatter) for scatter in self.nugget_to_scatter.values()] + for scatter, annotation in self.nugget_to_displayed_items.values(): + self.gl_widget.removeItem(scatter) + self.gl_widget.removeItem(annotation) self.fullscreen_window = None - self.nugget_to_scatter = {} + self.nugget_to_displayed_items = {} self.currently_highlighted_nugget = None From e32c83275f70814effd05df59fd081b071e34200 Mon Sep 17 00:00:00 2001 From: eneapane Date: Mon, 1 Jul 2024 14:58:00 +0200 Subject: [PATCH 20/85] Add list of most likely choices in the interactive matching widget --- wannadb_ui/interactive_matching.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index f2218f74..4d5593f2 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -89,6 +89,10 @@ def __init__(self, interactive_matching_widget): self.description.setFont(LABEL_FONT) self.layout.addWidget(self.description) + self.likely_nuggets = QLabel("") + self.likely_nuggets.setFont(LABEL_FONT) + self.layout.addWidget(self.likely_nuggets) + # nugget list self.num_nuggets_above_label = QLabel("") self.num_nuggets_above_label.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -113,6 +117,8 @@ def update_nuggets(self, feedback_request): "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in nuggets]), "max_distance": feedback_request["max-distance"] } + + self._process_likely_nuggets_label(nuggets, feedback_request["max-distance"]) self.nugget_list.update_item_list(nuggets, params) if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText(f"... and {feedback_request['num-nuggets-above']} more cells that will be left empty ...") @@ -123,6 +129,18 @@ def update_nuggets(self, feedback_request): else: self.num_nuggets_below_label.setText("") + def _process_likely_nuggets_label(self, nuggets, max_distance): + nuggets_to_add = [] + TOP_NUGGETS = 5 #todo configure + for nugget in nuggets: + if max_distance < nugget[CachedDistanceSignal]: + nuggets_to_add.append(nugget) + if len(nuggets_to_add) > 0: + self.likely_nuggets.setText(f"Based upon your last choice, the top most likely choices are {', '.join(map(str, nuggets_to_add[:TOP_NUGGETS]))}.") + pass + else: + self.likely_nuggets.setText("") + def enable_input(self): self.nugget_list.enable_input() From 9585515ad15f019a2a48810ca0ef45bc35ed6923 Mon Sep 17 00:00:00 2001 From: eneapane Date: Mon, 1 Jul 2024 15:05:43 +0200 Subject: [PATCH 21/85] Make last commit's code more pythonic --- wannadb_ui/interactive_matching.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 4d5593f2..c23b98a9 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -130,14 +130,11 @@ def update_nuggets(self, feedback_request): self.num_nuggets_below_label.setText("") def _process_likely_nuggets_label(self, nuggets, max_distance): - nuggets_to_add = [] - TOP_NUGGETS = 5 #todo configure - for nugget in nuggets: - if max_distance < nugget[CachedDistanceSignal]: - nuggets_to_add.append(nugget) - if len(nuggets_to_add) > 0: - self.likely_nuggets.setText(f"Based upon your last choice, the top most likely choices are {', '.join(map(str, nuggets_to_add[:TOP_NUGGETS]))}.") - pass + TOP_NUGGETS = 5 + nuggets_to_add = [nugget for nugget in nuggets if max_distance < nugget[CachedDistanceSignal]] + if nuggets_to_add: + top_nuggets = ', '.join(map(str, nuggets_to_add[:TOP_NUGGETS])) + self.likely_nuggets.setText(f"Based upon your last choice, the top most likely choices are {top_nuggets}.") else: self.likely_nuggets.setText("") From 26966664ff97aab95081a001ddc28bac2c891de7 Mon Sep 17 00:00:00 2001 From: Johanna Herbst Date: Wed, 3 Jul 2024 11:50:15 +0200 Subject: [PATCH 22/85] adjust annotation boxes for scatterplot/barchart adjust plot layout to prevent annotation boxes from getting out of the plot window in fullscreen mode --- wannadb_ui/visualizations.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index aaa138f7..412012a0 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -217,6 +217,7 @@ def plot_bar_chart(self): if self.window is not None: self.window.close() + fig = Figure() ax = fig.add_subplot(111) texts, distances = zip(*self.data) @@ -227,8 +228,8 @@ def plot_bar_chart(self): ax.set_xticks([]) ax.set_ylabel('Cosine Similarity', fontsize=15) ax.set_xlabel('Information Nuggets', fontsize=15) - fig.tight_layout() - + #fig.tight_layout() + fig.subplots_adjust(left=0.115, right=0.920, top=0.945, bottom=0.065) self.bar_chart_canvas = FigureCanvas(fig) self.window = QMainWindow() self.window.setWindowTitle("Bar Chart") @@ -357,7 +358,8 @@ def show_scatter_plot(self): ax.set_xlim(min(rounded_distances) - 0.05, max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility ax.set_yticks([]) # Remove y-axis labels to avoid confusion - fig.tight_layout() + fig.subplots_adjust(left=0.020, right=0.980, top=0.940, bottom=0.075) + #fig.tight_layout() # Create canvas self.scatter_plot_canvas = FigureCanvas(fig) @@ -403,7 +405,7 @@ def on_pick(self, event): # Update annotation text and position self.annotation.xy = (self.distances[ind], self.y[ind]) - text = f"Distance: {self.distances[ind]:.3f}\nText: {self.texts[ind]}" + text = f"Text: {self.texts[ind]}\nValue: {self.distances[ind]:.3f}" self.annotation.set_text(text) self.annotation.set_visible(True) self.scatter_plot_canvas.draw_idle() From 51980c19fd5f30f9f28ea4f6871ef03b8d008d7a Mon Sep 17 00:00:00 2001 From: Johanna Herbst Date: Sat, 6 Jul 2024 11:37:23 +0200 Subject: [PATCH 23/85] change colormap of scatterplot change colormap of scatterplot to represent distances (same color for same distance) --- wannadb_ui/visualizations.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 412012a0..ed490c4e 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -349,7 +349,9 @@ def show_scatter_plot(self): # Generating a list of colors for each point num_points = len(distances) - colors = plt.cm.jet(np.linspace(0, 1, num_points)) + colormap = plt.cm.jet + norm = plt.Normalize(min(rounded_distances), max(rounded_distances)) + colors = colormap(norm(rounded_distances)) # Plot the points scatter = ax.scatter(rounded_distances, y, c=colors, alpha=0.75, picker=True) # Enable picking From 0473890520bd91c65a0df2a854cfd1449e381752 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Wed, 10 Jul 2024 02:12:34 +0200 Subject: [PATCH 24/85] enhance 3D grid by adding distances to annotations and possibility to display best guesses from other documents --- wannadb_ui/interactive_matching.py | 36 +++++++-- wannadb_ui/visualizations.py | 122 +++++++++++++++++++++++------ 2 files changed, 128 insertions(+), 30 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index c23b98a9..e15fcb45 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -53,8 +53,8 @@ def handle_feedback_request(self, feedback_request): self.enable_input() self.show_nugget_list_widget() - def get_document_feedback(self, nugget): - self.document_widget.update_document(nugget) + def get_document_feedback(self, nugget, other_best_guesses): + self.document_widget.update_document(nugget, other_best_guesses) self.show_document_widget() def show_nugget_list_widget(self): @@ -120,6 +120,7 @@ def update_nuggets(self, feedback_request): self._process_likely_nuggets_label(nuggets, feedback_request["max-distance"]) self.nugget_list.update_item_list(nuggets, params) + self.update_other_best_guesses(nuggets) if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText(f"... and {feedback_request['num-nuggets-above']} more cells that will be left empty ...") else: @@ -144,12 +145,18 @@ def enable_input(self): def disable_input(self): self.nugget_list.disable_input() + def update_other_best_guesses(self, other_best_guesses): + for item_widget in self.nugget_list.item_widgets: + item_widget.update_other_best_guesses(other_best_guesses) + + class NuggetListItemWidget(CustomScrollableListItem): def __init__(self, nugget_list_widget): super(NuggetListItemWidget, self).__init__(nugget_list_widget) self.nugget_list_widget = nugget_list_widget self.nugget = None + self.other_best_guesses = None self.setFixedHeight(45) self.setObjectName("nuggetListItemWidget") @@ -239,6 +246,9 @@ def update_item(self, item, params=None): # self.info_button.setText(f"{str(round(self.nugget[CachedDistanceSignal], 2)).ljust(4)}") + def update_other_best_guesses(self, other_best_guesses): + self.other_best_guesses = [other_best_guess for other_best_guess in other_best_guesses if other_best_guess != self.nugget] + def _match_button_clicked(self): self.nugget_list_widget.interactive_matching_widget.main_window.give_feedback_task({ "message": "is-match", @@ -247,7 +257,7 @@ def _match_button_clicked(self): }) def _fix_button_clicked(self): - self.nugget_list_widget.interactive_matching_widget.get_document_feedback(self.nugget) + self.nugget_list_widget.interactive_matching_widget.get_document_feedback(self.nugget, self.other_best_guesses) # def _info_button_clicked(self): # lines = [] @@ -288,6 +298,7 @@ def __init__(self, interactive_matching_widget): self.document = None self.original_nugget = None self.current_nugget = None + self.current_other_best_guesses = None self.base_formatted_text = "" self.idx_mapper = {} self.nuggets_in_order = [] @@ -334,7 +345,7 @@ def __init__(self, interactive_matching_widget): self.upper_buttons_widget_layout.addWidget(self.scatter_plot_widget) self.visualizer = EmbeddingVisualizerWidget() - self.visualizer.setFixedHeight(200) + self.visualizer.setFixedHeight(300) self.layout.addWidget(self.visualizer) self.buttons_widget = QWidget() @@ -442,21 +453,22 @@ def _highlight_current_nugget(self): self.text_edit.setText("") self.text_edit.textCursor().insertHtml(formatted_text) - self.visualizer.highlight_nugget(self.current_nugget) + self.visualizer.highlight_selected_nugget(self.current_nugget) else: self.text_edit.setText("") self.text_edit.textCursor().insertHtml(self.base_formatted_text) self.suggestion_list.update_item_list(self.nuggets_sorted_by_distance, self.current_nugget) - def update_document(self, nugget): + def update_document(self, nugget, other_best_guesses): self.document = nugget.document self.original_nugget = nugget self.current_nugget = nugget + self.current_other_best_guesses = other_best_guesses self.nuggets_sorted_by_distance = list(sorted(self.document.nuggets, key=lambda x: x[CachedDistanceSignal])) self.nuggets_in_order = list(sorted(self.document.nuggets, key=lambda x: x.start_char)) self.custom_selection_item_widget.hide() - self.update_nuggets(self.document.nuggets) + self.update_nuggets(self.document.nuggets, self.current_other_best_guesses) self.old_start = -1 self.old_end = -1 @@ -517,6 +529,7 @@ def update_document(self, nugget): self.base_formatted_text = "" self._highlight_current_nugget() + self._highlight_best_guess(self.nuggets_sorted_by_distance[0] if len(self.nuggets_sorted_by_distance) > 0 else None) scroll_cursor = QTextCursor(self.text_edit.document()) scroll_cursor.setPosition(nugget.start_char) @@ -547,12 +560,19 @@ def disable_input(self): def update_attribute(self, attribute): self.visualizer.display_attribute_embedding(attribute) - def update_nuggets(self, nuggets): + def update_nuggets(self, nuggets, other_best_guesses): if len(nuggets) == 0: return self.visualizer.reset() self.visualizer.display_nugget_embedding(nuggets) + self.visualizer.update_other_best_guesses(other_best_guesses) + + def _highlight_best_guess(self, best_guess): + if best_guess is None: + return + + self.visualizer.highlight_best_guess(best_guess) class SuggestionListItemWidget(CustomScrollableListItem): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index ed490c4e..7d7f94bd 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -11,10 +11,11 @@ from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from pyqtgraph import Color from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem, GLTextItem from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, TSNEDimensionReducedTextEmbeddingSignal, \ - PCADimensionReducedLabelEmbeddingSignal + PCADimensionReducedLabelEmbeddingSignal, CachedDistanceSignal logger: logging.Logger = logging.getLogger(__name__) @@ -22,6 +23,7 @@ BLUE = pg.mkColor('blue') GREEN = pg.mkColor('green') WHITE = pg.mkColor('white') +YELLOW = pg.mkColor('yellow') EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) @@ -45,8 +47,8 @@ def add_grids(widget): widget.addItem(grid_yz) -def update_grid(gl_widget, points_to_display, color, annotation_text) -> (GLScatterPlotItem, GLTextItem): - scatter = GLScatterPlotItem(pos=points_to_display, color=color, size=3, pxMode=True) +def update_grid(gl_widget, points_to_display, color, annotation_text, size=3) -> (GLScatterPlotItem, GLTextItem): + scatter = GLScatterPlotItem(pos=points_to_display, color=color, size=size, pxMode=True) annotation = GLTextItem(pos=[points_to_display[0][0], points_to_display[0][1], points_to_display[0][2]], color=WHITE, text=annotation_text, @@ -56,6 +58,10 @@ def update_grid(gl_widget, points_to_display, color, annotation_text) -> (GLScat return scatter, annotation +def build_annotation_text(nugget) -> str: + return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" + + class EmbeddingVisualizerWindow(QMainWindow): def __init__(self, attribute, nuggets, currently_highlighted_nugget): super(EmbeddingVisualizerWindow, self).__init__() @@ -78,7 +84,7 @@ def __init__(self, attribute, nuggets, currently_highlighted_nugget): self.copy_state(attribute, nuggets) if currently_highlighted_nugget is not None: - self.highlight_nugget(currently_highlighted_nugget) + self.highlight_selected_nugget(currently_highlighted_nugget) def closeEvent(self, event): event.accept() @@ -92,23 +98,28 @@ def copy_state(self, attribute, nuggets): for nugget in nuggets: nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) - scatter, annotation = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN, nugget.text) + scatter, annotation = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN, + build_annotation_text(nugget)) self.nugget_to_displayed_items[nugget] = (scatter, annotation) - def highlight_nugget(self, nugget): - scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] - - if scatter_to_highlight is None: - logger.warning("Couldn't find nugget to highlight") - return + def highlight_selected_nugget(self, nugget): + self._highlight_nugget(nugget, BLUE, 10) if self.currently_highlighted_nugget is not None: currently_highlighted_scatter, _ = self.nugget_to_displayed_items[self.currently_highlighted_nugget] currently_highlighted_scatter.setData(color=GREEN, size=3) - scatter_to_highlight.setData(color=BLUE, size=10) self.currently_highlighted_nugget = nugget + def _highlight_nugget(self, nugget, new_color, new_size): + scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] + + if scatter_to_highlight is None: + logger.warning("Couldn't find nugget to highlight") + return + + scatter_to_highlight.setData(color=new_color, size=new_size) + class EmbeddingVisualizerWidget(QWidget): def __init__(self): @@ -126,12 +137,23 @@ def __init__(self): self.fullscreen_button.clicked.connect(self._show_embedding_visualizer_window) self.layout.addWidget(self.fullscreen_button) + self.show_other_best_guesses_button = QPushButton("Show best guesses from other documents") + self.show_other_best_guesses_button.clicked.connect(self._display_other_best_guesses) + self.layout.addWidget(self.show_other_best_guesses_button) + + self.remove_other_best_guesses_button = QPushButton("Stop showing best guesses from other documents") + self.remove_other_best_guesses_button.setEnabled(False) + self.remove_other_best_guesses_button.clicked.connect(self._remove_other_best_guesses) + self.layout.addWidget(self.remove_other_best_guesses_button) + add_grids(self.gl_widget) self.fullscreen_window = None self.attribute = None self.nugget_to_displayed_items = {} self.currently_highlighted_nugget = None + self.best_guess = None + self.other_best_guesses = None def _show_embedding_visualizer_window(self): if self.fullscreen_window is None: @@ -153,27 +175,79 @@ def display_nugget_embedding(self, nuggets): for nugget in nuggets: self._add_nugget_embedding(nugget) + def _display_other_best_guesses(self): + for other_best_guess in self.other_best_guesses: + self._add_other_best_guess(other_best_guess) + + self.show_other_best_guesses_button.setEnabled(False) + self.remove_other_best_guesses_button.setEnabled(True) + + def _remove_other_best_guesses(self): + for nugget in self.other_best_guesses: + scatter, annotation = self.nugget_to_displayed_items.pop(nugget) + + self.gl_widget.removeItem(scatter) + self.gl_widget.removeItem(annotation) + + self.show_other_best_guesses_button.setEnabled(True) + self.remove_other_best_guesses_button.setEnabled(False) + + def update_other_best_guesses(self, other_best_guesses): + self.other_best_guesses = other_best_guesses + def _add_nugget_embedding(self, nugget): nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) - scatter, annotation = update_grid(self.gl_widget, nugget_embedding, GREEN, nugget.text) + scatter, annotation = update_grid(self.gl_widget, nugget_embedding, GREEN, build_annotation_text(nugget)) self.nugget_to_displayed_items[nugget] = (scatter, annotation) - def highlight_nugget(self, nugget): - scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] + def _add_other_best_guess(self, other_best_guess): + nugget_embedding: np.ndarray = np.array([other_best_guess[PCADimensionReducedTextEmbeddingSignal]]) + scatter, annotation = update_grid(self.gl_widget, nugget_embedding, YELLOW, + build_annotation_text(other_best_guess), 15) + self.nugget_to_displayed_items[other_best_guess] = (scatter, annotation) - if scatter_to_highlight is None: - logger.warning("Couldn't find nugget to highlight") - return + def highlight_selected_nugget(self, nugget): + (highlight_color, highlight_size), (reset_color, reset_size) = self._determine_update_values( + self.currently_highlighted_nugget, nugget) + + self._highlight_nugget(nugget, highlight_color, highlight_size) if self.currently_highlighted_nugget is not None: currently_highlighted_scatter, _ = self.nugget_to_displayed_items[self.currently_highlighted_nugget] - currently_highlighted_scatter.setData(color=GREEN, size=3) + currently_highlighted_scatter.setData(color=reset_color, size=reset_size) - scatter_to_highlight.setData(color=BLUE, size=10) self.currently_highlighted_nugget = nugget if self.fullscreen_window is not None: - self.fullscreen_window.highlight_nugget(nugget) + self.fullscreen_window.highlight_selected_nugget(nugget) + + def highlight_best_guess(self, nugget): + self.best_guess = nugget + + if self.best_guess == self.currently_highlighted_nugget: + self._highlight_nugget(nugget, BLUE, 15) + return + + self._highlight_nugget(nugget, WHITE, 15) + + def _highlight_nugget(self, nugget, new_color, new_size): + scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] + + if scatter_to_highlight is None: + logger.warning("Couldn't find nugget to highlight") + return + + scatter_to_highlight.setData(color=new_color, size=new_size) + + def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( + (int, Color), (int, Color)): + highlight_color = BLUE + highlight_size = 15 if newly_selected_nugget == self.best_guess else 10 + + reset_color = WHITE if previously_selected_nugget == self.best_guess else GREEN + reset_size = 15 if previously_selected_nugget == self.best_guess else 3 + + return (highlight_color, highlight_size), (reset_color, reset_size) def reset(self): for scatter, annotation in self.nugget_to_displayed_items.values(): @@ -183,6 +257,11 @@ def reset(self): self.fullscreen_window = None self.nugget_to_displayed_items = {} self.currently_highlighted_nugget = None + self.best_guess = None + self.other_best_guesses = None + + self.show_other_best_guesses_button.setEnabled(True) + self.remove_other_best_guesses_button.setEnabled(False) class BarChartVisualizerWidget(QWidget): @@ -217,7 +296,6 @@ def plot_bar_chart(self): if self.window is not None: self.window.close() - fig = Figure() ax = fig.add_subplot(111) texts, distances = zip(*self.data) From 6b005694d98283d8e82957fe031a239c344a965f Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 10 Jul 2024 11:22:20 +0200 Subject: [PATCH 25/85] Change buttons layout below 3D Grid --- wannadb_ui/visualizations.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 7d7f94bd..6050d937 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -4,7 +4,7 @@ import pyqtgraph.opengl as gl import numpy as np from PyQt6.QtGui import QFont -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel, QHBoxLayout from matplotlib import pyplot as plt from matplotlib.colors import LinearSegmentedColormap from matplotlib.patches import Rectangle @@ -127,24 +127,28 @@ def __init__(self): self.layout = QVBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) self.setLayout(self.layout) self.gl_widget = GLViewWidget() - self.gl_widget.setMinimumHeight(200) # Set the initial height of the grid to 200 + self.gl_widget.setMinimumHeight(300) # Set the initial height of the grid to 200 self.layout.addWidget(self.gl_widget) + self.best_guesses_widget = QWidget() + self.best_guesses_widget_layout = QHBoxLayout(self.best_guesses_widget) + self.best_guesses_widget_layout.setContentsMargins(0, 0, 0, 0) + self.best_guesses_widget_layout.setSpacing(0) self.fullscreen_button = QPushButton("Show 3D Grid in windowed fullscreen mode") self.fullscreen_button.clicked.connect(self._show_embedding_visualizer_window) - self.layout.addWidget(self.fullscreen_button) - + self.best_guesses_widget_layout.addWidget(self.fullscreen_button) self.show_other_best_guesses_button = QPushButton("Show best guesses from other documents") self.show_other_best_guesses_button.clicked.connect(self._display_other_best_guesses) - self.layout.addWidget(self.show_other_best_guesses_button) - + self.best_guesses_widget_layout.addWidget(self.show_other_best_guesses_button) self.remove_other_best_guesses_button = QPushButton("Stop showing best guesses from other documents") self.remove_other_best_guesses_button.setEnabled(False) self.remove_other_best_guesses_button.clicked.connect(self._remove_other_best_guesses) - self.layout.addWidget(self.remove_other_best_guesses_button) + self.best_guesses_widget_layout.addWidget(self.remove_other_best_guesses_button) + self.layout.addWidget(self.best_guesses_widget) add_grids(self.gl_widget) @@ -155,6 +159,8 @@ def __init__(self): self.best_guess = None self.other_best_guesses = None + + def _show_embedding_visualizer_window(self): if self.fullscreen_window is None: self.fullscreen_window = EmbeddingVisualizerWindow(attribute=self.attribute, From 80f5f3eb39aafb7bf1d2604649b0cf5eed5a8842 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 15 Jul 2024 15:20:29 +0200 Subject: [PATCH 26/85] add 3D grid enhancements to fullscreen grid --- wannadb_ui/visualizations.py | 144 +++++++++++++++++++---------------- 1 file changed, 79 insertions(+), 65 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 6050d937..04127ce1 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -62,12 +62,68 @@ def build_annotation_text(nugget) -> str: return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" +def add_other_best_guess(gl_widget, other_best_guess, nugget_to_displayed_items): + nugget_embedding: np.ndarray = np.array([other_best_guess[PCADimensionReducedTextEmbeddingSignal]]) + scatter, annotation = update_grid(gl_widget, nugget_embedding, YELLOW, + build_annotation_text(other_best_guess), 15) + nugget_to_displayed_items[other_best_guess] = (scatter, annotation) + + +def remove_nuggets_from_widget(nuggets_to_remove, nugget_to_displayed_items, gl_widget): + for nugget in nuggets_to_remove: + scatter, annotation = nugget_to_displayed_items.pop(nugget) + + gl_widget.removeItem(scatter) + gl_widget.removeItem(annotation) + + +def highlight_best_guess(best_guess, currently_highlighted_nugget, nugget_to_displayed_items): + if best_guess == currently_highlighted_nugget: + highlight_nugget(best_guess, nugget_to_displayed_items, BLUE, 15) + return + + highlight_nugget(best_guess, nugget_to_displayed_items, WHITE, 15) + + +def highlight_selected_nugget(nugget, currently_highlighted_nugget, best_guess, nugget_to_displayed_items): + (highlight_color, highlight_size), (reset_color, reset_size) = determine_update_values( + currently_highlighted_nugget, nugget, best_guess) + + highlight_nugget(nugget, nugget_to_displayed_items, highlight_color, highlight_size) + + if currently_highlighted_nugget is not None: + currently_highlighted_scatter, _ = nugget_to_displayed_items[currently_highlighted_nugget] + currently_highlighted_scatter.setData(color=reset_color, size=reset_size) + + +def highlight_nugget(nugget_to_highlight, nugget_to_displayed_items, new_color, new_size): + scatter_to_highlight, _ = nugget_to_displayed_items[nugget_to_highlight] + + if scatter_to_highlight is None: + logger.warning("Couldn't find nugget to highlight") + return + + scatter_to_highlight.setData(color=new_color, size=new_size) + + +def determine_update_values(previously_selected_nugget, newly_selected_nugget, best_guess) -> ( + (int, Color), (int, Color)): + highlight_color = BLUE + highlight_size = 15 if newly_selected_nugget == best_guess else 10 + + reset_color = WHITE if previously_selected_nugget == best_guess else GREEN + reset_size = 15 if previously_selected_nugget == best_guess else 3 + + return (highlight_color, highlight_size), (reset_color, reset_size) + + class EmbeddingVisualizerWindow(QMainWindow): - def __init__(self, attribute, nuggets, currently_highlighted_nugget): + def __init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess): super(EmbeddingVisualizerWindow, self).__init__() self.nugget_to_displayed_items = {} self.currently_highlighted_nugget = None + self.best_guess = best_guess self.setWindowTitle("3D Grid Visualizer") self.setGeometry(100, 100, 800, 600) @@ -83,14 +139,13 @@ def __init__(self, attribute, nuggets, currently_highlighted_nugget): add_grids(self.fullscreen_gl_widget) self.copy_state(attribute, nuggets) - if currently_highlighted_nugget is not None: - self.highlight_selected_nugget(currently_highlighted_nugget) + highlight_best_guess(best_guess, currently_highlighted_nugget, self.nugget_to_displayed_items) + self.highlight_selected_nugget(currently_highlighted_nugget) def closeEvent(self, event): event.accept() def copy_state(self, attribute, nuggets): - update_grid(self.fullscreen_gl_widget, [attribute[PCADimensionReducedLabelEmbeddingSignal]], RED, @@ -103,22 +158,16 @@ def copy_state(self, attribute, nuggets): self.nugget_to_displayed_items[nugget] = (scatter, annotation) def highlight_selected_nugget(self, nugget): - self._highlight_nugget(nugget, BLUE, 10) - - if self.currently_highlighted_nugget is not None: - currently_highlighted_scatter, _ = self.nugget_to_displayed_items[self.currently_highlighted_nugget] - currently_highlighted_scatter.setData(color=GREEN, size=3) + highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, self.nugget_to_displayed_items) self.currently_highlighted_nugget = nugget - def _highlight_nugget(self, nugget, new_color, new_size): - scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] + def display_other_best_guesses(self, other_best_guesses): + for other_best_guess in other_best_guesses: + add_other_best_guess(self.fullscreen_gl_widget, other_best_guess, self.nugget_to_displayed_items) - if scatter_to_highlight is None: - logger.warning("Couldn't find nugget to highlight") - return - - scatter_to_highlight.setData(color=new_color, size=new_size) + def remove_other_best_guesses(self, other_best_guesses): + remove_nuggets_from_widget(other_best_guesses, self.nugget_to_displayed_items, self.fullscreen_gl_widget) class EmbeddingVisualizerWidget(QWidget): @@ -159,13 +208,12 @@ def __init__(self): self.best_guess = None self.other_best_guesses = None - - def _show_embedding_visualizer_window(self): if self.fullscreen_window is None: self.fullscreen_window = EmbeddingVisualizerWindow(attribute=self.attribute, nuggets=self.nugget_to_displayed_items.keys(), - currently_highlighted_nugget=self.currently_highlighted_nugget) + currently_highlighted_nugget=self.currently_highlighted_nugget, + best_guess=self.best_guess) self.fullscreen_window.show() def return_from_embedding_visualizer_window(self): @@ -183,21 +231,23 @@ def display_nugget_embedding(self, nuggets): def _display_other_best_guesses(self): for other_best_guess in self.other_best_guesses: - self._add_other_best_guess(other_best_guess) + add_other_best_guess(self.gl_widget, other_best_guess, self.nugget_to_displayed_items) self.show_other_best_guesses_button.setEnabled(False) self.remove_other_best_guesses_button.setEnabled(True) - def _remove_other_best_guesses(self): - for nugget in self.other_best_guesses: - scatter, annotation = self.nugget_to_displayed_items.pop(nugget) + if self.fullscreen_window is not None: + self.fullscreen_window.display_other_best_guesses(self.other_best_guesses) - self.gl_widget.removeItem(scatter) - self.gl_widget.removeItem(annotation) + def _remove_other_best_guesses(self): + remove_nuggets_from_widget(self.other_best_guesses, self.nugget_to_displayed_items, self.gl_widget) self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) + if self.fullscreen_window is not None: + self.fullscreen_window.remove_other_best_guesses(self.other_best_guesses) + def update_other_best_guesses(self, other_best_guesses): self.other_best_guesses = other_best_guesses @@ -206,54 +256,18 @@ def _add_nugget_embedding(self, nugget): scatter, annotation = update_grid(self.gl_widget, nugget_embedding, GREEN, build_annotation_text(nugget)) self.nugget_to_displayed_items[nugget] = (scatter, annotation) - def _add_other_best_guess(self, other_best_guess): - nugget_embedding: np.ndarray = np.array([other_best_guess[PCADimensionReducedTextEmbeddingSignal]]) - scatter, annotation = update_grid(self.gl_widget, nugget_embedding, YELLOW, - build_annotation_text(other_best_guess), 15) - self.nugget_to_displayed_items[other_best_guess] = (scatter, annotation) - def highlight_selected_nugget(self, nugget): - (highlight_color, highlight_size), (reset_color, reset_size) = self._determine_update_values( - self.currently_highlighted_nugget, nugget) - - self._highlight_nugget(nugget, highlight_color, highlight_size) - - if self.currently_highlighted_nugget is not None: - currently_highlighted_scatter, _ = self.nugget_to_displayed_items[self.currently_highlighted_nugget] - currently_highlighted_scatter.setData(color=reset_color, size=reset_size) - - self.currently_highlighted_nugget = nugget + highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, self.nugget_to_displayed_items) if self.fullscreen_window is not None: self.fullscreen_window.highlight_selected_nugget(nugget) + self.currently_highlighted_nugget = nugget + def highlight_best_guess(self, nugget): self.best_guess = nugget - if self.best_guess == self.currently_highlighted_nugget: - self._highlight_nugget(nugget, BLUE, 15) - return - - self._highlight_nugget(nugget, WHITE, 15) - - def _highlight_nugget(self, nugget, new_color, new_size): - scatter_to_highlight, _ = self.nugget_to_displayed_items[nugget] - - if scatter_to_highlight is None: - logger.warning("Couldn't find nugget to highlight") - return - - scatter_to_highlight.setData(color=new_color, size=new_size) - - def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( - (int, Color), (int, Color)): - highlight_color = BLUE - highlight_size = 15 if newly_selected_nugget == self.best_guess else 10 - - reset_color = WHITE if previously_selected_nugget == self.best_guess else GREEN - reset_size = 15 if previously_selected_nugget == self.best_guess else 3 - - return (highlight_color, highlight_size), (reset_color, reset_size) + highlight_best_guess(nugget, self.currently_highlighted_nugget, self.nugget_to_displayed_items) def reset(self): for scatter, annotation in self.nugget_to_displayed_items.values(): From 3b08a0b326096407e52d95d4ad24fb5fef20cb08 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 20 Jul 2024 18:13:57 +0200 Subject: [PATCH 27/85] make bar chart horizontally scrollable --- wannadb_ui/visualizations.py | 70 +++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 04127ce1..503204ba 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,21 +1,23 @@ import logging +import numpy as np import pyqtgraph as pg import pyqtgraph.opengl as gl -import numpy as np +from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QLabel, QHBoxLayout +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QHBoxLayout, QFrame, QScrollArea, \ + QApplication from matplotlib import pyplot as plt -from matplotlib.colors import LinearSegmentedColormap -from matplotlib.patches import Rectangle -from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.colors import LinearSegmentedColormap +from matplotlib.figure import Figure +from matplotlib.patches import Rectangle from pyqtgraph import Color from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem, GLTextItem -from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, TSNEDimensionReducedTextEmbeddingSignal, \ - PCADimensionReducedLabelEmbeddingSignal, CachedDistanceSignal +from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ + CachedDistanceSignal logger: logging.Logger = logging.getLogger(__name__) @@ -26,6 +28,12 @@ YELLOW = pg.mkColor('yellow') EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) +app = QApplication([]) +screen = app.primaryScreen() +screen_geometry = screen.geometry() +WINDOW_WIDTH = int(screen_geometry.width() * 0.7) +WINDOW_HEIGHT = int(screen_geometry.height() * 0.7) + def get_colors(distances, color_start='red', color_end='blue'): cmap = LinearSegmentedColormap.from_list("CustomMap", [color_start, color_end]) @@ -126,7 +134,7 @@ def __init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess) self.best_guess = best_guess self.setWindowTitle("3D Grid Visualizer") - self.setGeometry(100, 100, 800, 600) + self.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) central_widget = QWidget() self.setCentralWidget(central_widget) @@ -158,7 +166,8 @@ def copy_state(self, attribute, nuggets): self.nugget_to_displayed_items[nugget] = (scatter, annotation) def highlight_selected_nugget(self, nugget): - highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, self.nugget_to_displayed_items) + highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, + self.nugget_to_displayed_items) self.currently_highlighted_nugget = nugget @@ -257,7 +266,8 @@ def _add_nugget_embedding(self, nugget): self.nugget_to_displayed_items[nugget] = (scatter, annotation) def highlight_selected_nugget(self, nugget): - highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, self.nugget_to_displayed_items) + highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, + self.nugget_to_displayed_items) if self.fullscreen_window is not None: self.fullscreen_window.highlight_selected_nugget(nugget) @@ -291,7 +301,7 @@ def __init__(self, parent=None): self.layout.setContentsMargins(0, 0, 0, 0) self.button = QPushButton("Show Bar Chart with cosine values") self.layout.addWidget(self.button) - self.data = [] # Initialize data as an empty dictionary + self.data = [] self.button.clicked.connect(self.show_bar_chart) self.window = None @@ -326,13 +336,35 @@ def plot_bar_chart(self): ax.set_xticks([]) ax.set_ylabel('Cosine Similarity', fontsize=15) ax.set_xlabel('Information Nuggets', fontsize=15) - #fig.tight_layout() fig.subplots_adjust(left=0.115, right=0.920, top=0.945, bottom=0.065) + for idx, rect in enumerate(self.bar): + height = rect.get_height() + ax.text( + rect.get_x() + rect.get_width() / 2, + height/2, + f'{texts[idx]}', + ha='center', + va='center', + rotation=90, # Rotate text by 90 degrees + fontsize=12, + color='white'# fontcolors[idx]# Optional: Adjust font size + ) + self.bar_chart_canvas = FigureCanvas(fig) + self.bar_chart_canvas.setMinimumWidth( + max(0.9 * WINDOW_WIDTH, len(texts) * 50)) # Set a minimum width based on number of bars + + scroll_area = QScrollArea() + scroll_area.setWidget(self.bar_chart_canvas) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.window = QMainWindow() self.window.setWindowTitle("Bar Chart") - self.window.setGeometry(100, 100, 800, 600) - self.window.setCentralWidget(self.bar_chart_canvas) + self.window.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) + self.window.setCentralWidget(scroll_area) self.bar_chart_toolbar = NavigationToolbar(self.bar_chart_canvas, self.window) self.window.addToolBar(self.bar_chart_toolbar) @@ -351,16 +383,12 @@ def plot_bar_chart(self): self.texts = texts self.distances = rounded_distances - # todo after value is confirmed or value not in document, reinitialize data - # self.window.destroyed.connect(self.cleanup) - def on_pick(self, event): if isinstance(event.artist, Rectangle): patch = event.artist index = self.bar.get_children().index(patch) - text = f"Infomation Nugget: \n{self.texts[index]} \n\n Value: {self.distances[index]}" + text = f"Information Nugget: \n{self.texts[index]} \n\n Value: {self.distances[index]}" self.annotation.set_text(text) - # if patch.get_x() + patch.get_width() > 20: annotation_x = patch.get_x() + patch.get_width() / 2 annotation_y = patch.get_height() / 2 self.annotation.xy = (annotation_x, annotation_y) @@ -459,7 +487,7 @@ def show_scatter_plot(self): max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility ax.set_yticks([]) # Remove y-axis labels to avoid confusion fig.subplots_adjust(left=0.020, right=0.980, top=0.940, bottom=0.075) - #fig.tight_layout() + # fig.tight_layout() # Create canvas self.scatter_plot_canvas = FigureCanvas(fig) @@ -467,7 +495,7 @@ def show_scatter_plot(self): # Create a new window for the plot self.window = QMainWindow() self.window.setWindowTitle("Scatter Plot") - self.window.setGeometry(100, 100, 800, 600) + self.window.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) # Set the central widget of the window to the canvas self.window.setCentralWidget(self.scatter_plot_canvas) From a8ef4f3c48ebf58b9d7612632f16303541e0f9c9 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 20 Jul 2024 23:01:09 +0200 Subject: [PATCH 28/85] Adjust on-click bar chart --- wannadb_ui/visualizations.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 503204ba..87def58a 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -304,6 +304,7 @@ def __init__(self, parent=None): self.data = [] self.button.clicked.connect(self.show_bar_chart) self.window = None + self.current_annotation_index = None def append_data(self, data_tuple): self.data.append(data_tuple) @@ -387,14 +388,20 @@ def on_pick(self, event): if isinstance(event.artist, Rectangle): patch = event.artist index = self.bar.get_children().index(patch) - text = f"Information Nugget: \n{self.texts[index]} \n\n Value: {self.distances[index]}" - self.annotation.set_text(text) - annotation_x = patch.get_x() + patch.get_width() / 2 - annotation_y = patch.get_height() / 2 - self.annotation.xy = (annotation_x, annotation_y) - self.annotation.set_visible(True) + if self.current_annotation_index == index and self.annotation.get_visible(): + # If the same bar is clicked again, hide the annotation + self.annotation.set_visible(False) + self.current_annotation_index = None + else: + # Show annotation for the clicked bar + text = f"Information Nugget: \n{self.texts[index]} \n\n Value: {self.distances[index]}" + self.annotation.set_text(text) + annotation_x = patch.get_x() + patch.get_width() / 2 + annotation_y = patch.get_height() / 2 + self.annotation.xy = (annotation_x, annotation_y) + self.annotation.set_visible(True) + self.current_annotation_index = index self.bar_chart_canvas.draw_idle() - def clear_data(self): self.data = [] self.bar = None From 629284eb59e13b2983831c660ef13c67187481dd Mon Sep 17 00:00:00 2001 From: nils-bz Date: Fri, 26 Jul 2024 01:29:42 +0200 Subject: [PATCH 29/85] implement simple visualizer for document overview --- wannadb/matching/matching.py | 4 ++- wannadb_ui/interactive_matching.py | 39 ++++++++++++++++++++++++------ wannadb_ui/visualizations.py | 28 ++++++++++++++++++--- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 289fdfed..55a1d8d0 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -260,6 +260,7 @@ def _sort_remaining_documents(): doc.nuggets[doc[CurrentMatchIndexSignal]] for doc in selected_documents) ) ) + remaining_nuggets = tuple([doc.nuggets[doc[CurrentMatchIndexSignal]] for doc in remaining_documents]) num_feedback += 1 statistics[attribute.name]["num_feedback"] += 1 t0 = time.time() @@ -268,6 +269,7 @@ def _sort_remaining_documents(): { "max-distance": self._max_distance, "nuggets": feedback_nuggets, + "remaining-nuggets": remaining_nuggets, "attribute": attribute, "num-feedback": num_feedback, "num-nuggets-above": num_nuggets_above, @@ -444,7 +446,7 @@ def run_nugget_pipeline(nuggets): [feedback_result["nugget"]], document.nuggets, statistics["distance"] - )[0] + ) [0] for nugget, new_distance in zip(document.nuggets, new_distances): if distances_based_on_label or new_distance < nugget[CachedDistanceSignal]: nugget[CachedDistanceSignal] = new_distance diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index e15fcb45..c6aabf8f 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -4,14 +4,16 @@ from PyQt6 import QtGui from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon, QTextCursor -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy, \ + QBoxLayout from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW -from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget +from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ + EmbeddingVisualizerWindow logger = logging.getLogger(__name__) @@ -93,6 +95,19 @@ def __init__(self, interactive_matching_widget): self.likely_nuggets.setFont(LABEL_FONT) self.layout.addWidget(self.likely_nuggets) + # suggestion visualizer + self.suggestion_visualizer = EmbeddingVisualizerWindow() + self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") + self.suggestion_visualizer_button.setFont(BUTTON_FONT) + self.suggestion_visualizer_button.setMaximumWidth(240) + self.suggestion_visualizer_button.setVisible(False) + self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) + self.suggestion_visualizer_layout = QHBoxLayout() + self.suggestion_visualizer_layout.setContentsMargins(0, 0, 0, 0) + self.suggestion_visualizer_layout.setSpacing(10) + self.suggestion_visualizer_layout.addWidget(self.suggestion_visualizer_button, 0, Qt.AlignmentFlag.AlignRight) + self.layout.addLayout(self.suggestion_visualizer_layout) + # nugget list self.num_nuggets_above_label = QLabel("") self.num_nuggets_above_label.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -112,15 +127,23 @@ def __init__(self, interactive_matching_widget): def update_nuggets(self, feedback_request): self.description.setText("Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") - nuggets = feedback_request["nuggets"] + feedback_nuggets = feedback_request["nuggets"] + remaining_nuggets = feedback_request["remaining-nuggets"] + attribute = feedback_request["attribute"] params = { - "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in nuggets]), + "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), "max_distance": feedback_request["max-distance"] } - self._process_likely_nuggets_label(nuggets, feedback_request["max-distance"]) - self.nugget_list.update_item_list(nuggets, params) - self.update_other_best_guesses(nuggets) + self.suggestion_visualizer_button.setVisible(True) + + self._process_likely_nuggets_label(feedback_nuggets, feedback_request["max-distance"]) + + self.nugget_list.update_item_list(feedback_nuggets, params) + self.update_other_best_guesses(feedback_nuggets) + if len(feedback_nuggets) > 0: + self.suggestion_visualizer.update_grid(attribute, feedback_nuggets + remaining_nuggets, feedback_nuggets[0], feedback_nuggets[0]) + if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText(f"... and {feedback_request['num-nuggets-above']} more cells that will be left empty ...") else: @@ -149,6 +172,8 @@ def update_other_best_guesses(self, other_best_guesses): for item_widget in self.nugget_list.item_widgets: item_widget.update_other_best_guesses(other_best_guesses) + def _show_suggestion_visualizer(self): + self.suggestion_visualizer.setVisible(True) class NuggetListItemWidget(CustomScrollableListItem): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 87def58a..047c2253 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -126,7 +126,7 @@ def determine_update_values(previously_selected_nugget, newly_selected_nugget, b class EmbeddingVisualizerWindow(QMainWindow): - def __init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess): + def __init__(self, attribute=None, nuggets=None, currently_highlighted_nugget=None, best_guess=None): super(EmbeddingVisualizerWindow, self).__init__() self.nugget_to_displayed_items = {} @@ -145,10 +145,14 @@ def __init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess) self.fullscreen_layout.addWidget(self.fullscreen_gl_widget) add_grids(self.fullscreen_gl_widget) - self.copy_state(attribute, nuggets) - highlight_best_guess(best_guess, currently_highlighted_nugget, self.nugget_to_displayed_items) - self.highlight_selected_nugget(currently_highlighted_nugget) + if (attribute is not None and + nuggets is not None and + currently_highlighted_nugget is not None and + best_guess is not None): + self.update_grid(attribute, nuggets, currently_highlighted_nugget, best_guess) + else: + self.setVisible(False) def closeEvent(self, event): event.accept() @@ -178,6 +182,22 @@ def display_other_best_guesses(self, other_best_guesses): def remove_other_best_guesses(self, other_best_guesses): remove_nuggets_from_widget(other_best_guesses, self.nugget_to_displayed_items, self.fullscreen_gl_widget) + def update_grid(self, attribute, nuggets, currently_highlighted_nugget, best_guess): + self.reset() + + self.copy_state(attribute, nuggets) + highlight_best_guess(best_guess, currently_highlighted_nugget, self.nugget_to_displayed_items) + self.highlight_selected_nugget(currently_highlighted_nugget) + + def reset(self): + for scatter, annotation in self.nugget_to_displayed_items.values(): + self.fullscreen_gl_widget.removeItem(scatter) + self.fullscreen_gl_widget.removeItem(annotation) + + self.nugget_to_displayed_items = {} + self.currently_highlighted_nugget = None + self.best_guess = None + class EmbeddingVisualizerWidget(QWidget): def __init__(self): From dca5725e3d3846b6f492b5659269a6f3a922414b Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 27 Jul 2024 05:31:39 +0200 Subject: [PATCH 30/85] several grid related enhancements and complete refactoring of visualizations module --- wannadb/data/data.py | 6 + wannadb/data/signals.py | 7 + wannadb/matching/matching.py | 15 +- wannadb_ui/interactive_matching.py | 64 +++-- wannadb_ui/visualizations.py | 394 ++++++++++++++++------------- 5 files changed, 292 insertions(+), 194 deletions(-) diff --git a/wannadb/data/data.py b/wannadb/data/data.py index 66c5e69a..a1ba66fa 100644 --- a/wannadb/data/data.py +++ b/wannadb/data/data.py @@ -150,6 +150,7 @@ def __init__(self, name: str) -> None: :param name: name of the attribute (must be unique in the document base) """ self._name: str = name + self._confirmed_matches: List[InformationNugget] = [] self._signals: Dict[str, BaseSignal] = {} @@ -170,6 +171,11 @@ def name(self) -> str: """Name of the attribute.""" return self._name + @property + def confirmed_matches(self) -> List[InformationNugget]: + """All nuggets that have been explicitly confirmed as match for this attribute by the user.""" + return self._confirmed_matches + @property def signals(self) -> Dict[str, BaseSignal]: """Signals associated with the attribute.""" diff --git a/wannadb/data/signals.py b/wannadb/data/signals.py index 7a1a394e..ea8607b1 100644 --- a/wannadb/data/signals.py +++ b/wannadb/data/signals.py @@ -403,3 +403,10 @@ class DocumentSentenceEmbeddingSignal(BaseNumpyArraySignal): """Embedding of the sentences of a document.""" identifier: str = "DocumentSentenceEmbeddingSignal" do_serialize: bool = True + + +@register_signal +class CurrentThresholdSignal(BaseFloatSignal): + """Current threshold associated with an attribute.""" + identifier: str = "CurrentThresholdSignal" + do_serialize: bool = True diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 55a1d8d0..9b9acb2c 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -9,7 +9,7 @@ from wannadb.configuration import BasePipelineElement, register_configurable_element, Pipeline from wannadb.data.data import Document, DocumentBase, InformationNugget from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ - SentenceStartCharsSignal, CurrentMatchIndexSignal, LabelSignal, ExtractorNameSignal + SentenceStartCharsSignal, CurrentMatchIndexSignal, LabelSignal, ExtractorNameSignal, CurrentThresholdSignal from wannadb.interaction import BaseInteractionCallback from wannadb.matching.custom_match_extraction import BaseCustomMatchExtractor from wannadb.matching.distance import BaseDistance @@ -131,6 +131,7 @@ def _call( logger.info(f"Matching attribute '{attribute.name}'.") start_matching: float = time.time() self._max_distance = self._default_max_distance + attribute[CurrentThresholdSignal] = CurrentThresholdSignal(self._max_distance) statistics[attribute.name]["max_distances"] = [self._max_distance] statistics[attribute.name]["feedback_durations"] = [] if self.store_best_guesses: @@ -260,7 +261,7 @@ def _sort_remaining_documents(): doc.nuggets[doc[CurrentMatchIndexSignal]] for doc in selected_documents) ) ) - remaining_nuggets = tuple([doc.nuggets[doc[CurrentMatchIndexSignal]] for doc in remaining_documents]) + all_guessed_nugget_matches = tuple([doc.nuggets[doc[CurrentMatchIndexSignal]] for doc in document_base.documents]) num_feedback += 1 statistics[attribute.name]["num_feedback"] += 1 t0 = time.time() @@ -269,7 +270,7 @@ def _sort_remaining_documents(): { "max-distance": self._max_distance, "nuggets": feedback_nuggets, - "remaining-nuggets": remaining_nuggets, + "all-guessed-nugget-matches": all_guessed_nugget_matches, "attribute": attribute, "num-feedback": num_feedback, "num-nuggets-above": num_nuggets_above, @@ -312,6 +313,7 @@ def _sort_remaining_documents(): min_dist = min(min_dist, feedback_nuggets[ix][CachedDistanceSignal]) if min_dist < self._max_distance: self._max_distance = min_dist + attribute[CurrentThresholdSignal] = CurrentThresholdSignal(min_dist) statistics[attribute.name]["max_distances"].append(min_dist) logger.info(f"NO MATCH IN DOCUMENT: Decreased the maximum distance to " f"{self._max_distance}.") @@ -359,6 +361,9 @@ def run_nugget_pipeline(nuggets): feedback_result["document"].attribute_mappings[attribute.name] = [confirmed_nugget] remaining_documents.remove(feedback_result["document"]) + # add this nugget as a confirmed match to the corresponding attribute + attribute.confirmed_matches.append(confirmed_nugget) + # update the distances for the other documents for document in remaining_documents: new_distances: np.ndarray = self._distance.compute_distances( @@ -440,6 +445,9 @@ def run_nugget_pipeline(nuggets): if doc in docs_with_added_nuggets: docs_with_added_nuggets.pop(doc) + # add this nugget as a confirmed match to the corresponding attribute + attribute.confirmed_matches.append(feedback_result["nugget"]) + # update the distances for the other documents for document in remaining_documents: new_distances: np.ndarray = self._distance.compute_distances( @@ -476,6 +484,7 @@ def run_nugget_pipeline(nuggets): max_dist = max(max_dist, feedback_nuggets[ix][CachedDistanceSignal]) if max_dist > self._max_distance: self._max_distance = max_dist + attribute[CurrentThresholdSignal] = CurrentThresholdSignal(max_dist) statistics[attribute.name]["max_distances"].append(max_dist) logger.info(f"CONFIRMED NUGGET FROM RANKED LIST: Increased the maximum distance" f"to {self._max_distance}.") diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index c6aabf8f..985464b6 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -11,7 +11,7 @@ PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ - CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW + CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, SUBHEADER_FONT from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ EmbeddingVisualizerWindow @@ -87,6 +87,10 @@ def __init__(self, interactive_matching_widget): self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(10) + self.current_threshold_label = QLabel() + self.current_threshold_label.setFont(SUBHEADER_FONT) + self.layout.addWidget(self.current_threshold_label) + self.description = QLabel("Please wait while WannaDB prepares the interactive table population.") self.description.setFont(LABEL_FONT) self.layout.addWidget(self.description) @@ -125,37 +129,47 @@ def __init__(self, interactive_matching_widget): self.layout.addWidget(self.nugget_list) def update_nuggets(self, feedback_request): - self.description.setText("Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." - "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") feedback_nuggets = feedback_request["nuggets"] - remaining_nuggets = feedback_request["remaining-nuggets"] + all_guessed_nugget_matches = feedback_request["all-guessed-nugget-matches"] attribute = feedback_request["attribute"] + current_threshold = feedback_request["max-distance"] + + self.description.setText( + "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." + "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") + self.current_threshold_label.setText(f"Current Threshold: {current_threshold}") + params = { "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), - "max_distance": feedback_request["max-distance"] + "max_distance": current_threshold } self.suggestion_visualizer_button.setVisible(True) - self._process_likely_nuggets_label(feedback_nuggets, feedback_request["max-distance"]) + self._process_likely_nuggets_label(feedback_nuggets, current_threshold) self.nugget_list.update_item_list(feedback_nuggets, params) self.update_other_best_guesses(feedback_nuggets) if len(feedback_nuggets) > 0: - self.suggestion_visualizer.update_grid(attribute, feedback_nuggets + remaining_nuggets, feedback_nuggets[0], feedback_nuggets[0]) + self.suggestion_visualizer.update_and_display_params(attribute=attribute, + nuggets=feedback_nuggets + all_guessed_nugget_matches, + currently_highlighted_nugget=None, + best_guess=feedback_nuggets[0]) if feedback_request["num-nuggets-above"] > 0: - self.num_nuggets_above_label.setText(f"... and {feedback_request['num-nuggets-above']} more cells that will be left empty ...") + self.num_nuggets_above_label.setText( + f"... and {feedback_request['num-nuggets-above']} more cells that will be left empty ...") else: self.num_nuggets_above_label.setText("") if feedback_request["num-nuggets-below"] > 0: - self.num_nuggets_below_label.setText(f"... and {feedback_request['num-nuggets-below']} more cells that will be populated ...") + self.num_nuggets_below_label.setText( + f"... and {feedback_request['num-nuggets-below']} more cells that will be populated ...") else: self.num_nuggets_below_label.setText("") - def _process_likely_nuggets_label(self, nuggets, max_distance): + def _process_likely_nuggets_label(self, nuggets, current_threshold): TOP_NUGGETS = 5 - nuggets_to_add = [nugget for nugget in nuggets if max_distance < nugget[CachedDistanceSignal]] + nuggets_to_add = [nugget for nugget in nuggets if current_threshold < nugget[CachedDistanceSignal]] if nuggets_to_add: top_nuggets = ', '.join(map(str, nuggets_to_add[:TOP_NUGGETS])) self.likely_nuggets.setText(f"Based upon your last choice, the top most likely choices are {top_nuggets}.") @@ -272,7 +286,8 @@ def update_item(self, item, params=None): # self.info_button.setText(f"{str(round(self.nugget[CachedDistanceSignal], 2)).ljust(4)}") def update_other_best_guesses(self, other_best_guesses): - self.other_best_guesses = [other_best_guess for other_best_guess in other_best_guesses if other_best_guess != self.nugget] + self.other_best_guesses = [other_best_guess for other_best_guess in other_best_guesses if + other_best_guess != self.nugget] def _match_button_clicked(self): self.nugget_list_widget.interactive_matching_widget.main_window.give_feedback_task({ @@ -323,14 +338,16 @@ def __init__(self, interactive_matching_widget): self.document = None self.original_nugget = None self.current_nugget = None + self.current_attribute = None self.current_other_best_guesses = None self.base_formatted_text = "" self.idx_mapper = {} self.nuggets_in_order = [] self.nuggets_sorted_by_distance = [] - self.description = QLabel("Please select the correct value by clicking on one of the highlighted snippets. You may also " - "highlight a different span of text in case the required value is not highlighted already.") + self.description = QLabel( + "Please select the correct value by clicking on one of the highlighted snippets. You may also " + "highlight a different span of text in case the required value is not highlighted already.") self.description.setFont(LABEL_FONT) self.layout.addWidget(self.description) @@ -467,7 +484,8 @@ def _handle_selection_changed(self): def _highlight_current_nugget(self): if self.current_nugget: mapped_start_char = self.idx_mapper[self.current_nugget.start_char] - mapped_end_char = self.idx_mapper[self.current_nugget.end_char] if self.current_nugget.end_char < len(self.document.text) else len(self.base_formatted_text) + mapped_end_char = self.idx_mapper[self.current_nugget.end_char] if self.current_nugget.end_char < len( + self.document.text) else len(self.base_formatted_text) formatted_text = ( f"{self.base_formatted_text[:mapped_start_char]}" @@ -493,7 +511,6 @@ def update_document(self, nugget, other_best_guesses): self.nuggets_sorted_by_distance = list(sorted(self.document.nuggets, key=lambda x: x[CachedDistanceSignal])) self.nuggets_in_order = list(sorted(self.document.nuggets, key=lambda x: x.start_char)) self.custom_selection_item_widget.hide() - self.update_nuggets(self.document.nuggets, self.current_other_best_guesses) self.old_start = -1 self.old_end = -1 @@ -547,6 +564,11 @@ def update_document(self, nugget, other_best_guesses): inside = False self.base_formatted_text += char self.idx_mapper[idx] = len(self.base_formatted_text) - 1 + + self.visualizer.update_and_display_params(attribute=self.current_attribute, + nuggets=self.document.nuggets, + currently_highlighted_nugget=nugget, + best_guess=self.nuggets_sorted_by_distance[0]) else: self.idx_mapper = {} for idx in range(len(self.document.text)): @@ -554,7 +576,8 @@ def update_document(self, nugget, other_best_guesses): self.base_formatted_text = "" self._highlight_current_nugget() - self._highlight_best_guess(self.nuggets_sorted_by_distance[0] if len(self.nuggets_sorted_by_distance) > 0 else None) + self._highlight_best_guess( + self.nuggets_sorted_by_distance[0] if len(self.nuggets_sorted_by_distance) > 0 else None) scroll_cursor = QTextCursor(self.text_edit.document()) scroll_cursor.setPosition(nugget.start_char) @@ -583,14 +606,14 @@ def disable_input(self): self.suggestion_list.disable_input() def update_attribute(self, attribute): - self.visualizer.display_attribute_embedding(attribute) + self.current_attribute = attribute def update_nuggets(self, nuggets, other_best_guesses): if len(nuggets) == 0: return self.visualizer.reset() - self.visualizer.display_nugget_embedding(nuggets) + self.visualizer.display_nugget_embeddings(nuggets) self.visualizer.update_other_best_guesses(other_best_guesses) def _highlight_best_guess(self, best_guess): @@ -640,7 +663,8 @@ def update_item(self, item, params=None): else: self.setStyleSheet(f"background-color: {LIGHT_YELLOW}") self.suggestion_list_widget.interactive_matching_widget.document_widget.update_barchart(self.get_nugget_data()) - self.suggestion_list_widget.interactive_matching_widget.document_widget.update_scatter_plot(self.get_nugget_data()) + self.suggestion_list_widget.interactive_matching_widget.document_widget.update_scatter_plot( + self.get_nugget_data()) def enable_input(self): pass diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 047c2253..876c710d 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,4 +1,6 @@ import logging +import math +from typing import List, Dict, Tuple, Union import numpy as np import pyqtgraph as pg @@ -16,8 +18,9 @@ from pyqtgraph import Color from pyqtgraph.opengl import GLViewWidget, GLScatterPlotItem, GLTextItem +from wannadb.data.data import InformationNugget, Attribute from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ - CachedDistanceSignal + CachedDistanceSignal, CurrentThresholdSignal logger: logging.Logger = logging.getLogger(__name__) @@ -26,7 +29,9 @@ GREEN = pg.mkColor('green') WHITE = pg.mkColor('white') YELLOW = pg.mkColor('yellow') +PURPLE = pg.mkColor('purple') EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) +DEFAULT_NUGGET_SIZE = 7 app = QApplication([]) screen = app.primaryScreen() @@ -42,96 +47,212 @@ def get_colors(distances, color_start='red', color_end='blue'): return colors -def add_grids(widget): - grid_xy = gl.GLGridItem() - widget.addItem(grid_xy) +def positions_equal(pos1: np.ndarray, pos2: np.ndarray) -> bool: + if pos1.shape != (1, 3) or pos2.shape != (1, 3): + return False - grid_xz = gl.GLGridItem() - grid_xz.rotate(90, 1, 0, 0) - widget.addItem(grid_xz) + return (math.isclose(pos1[0][0], pos2[0][0], rel_tol=1e-05, abs_tol=1e-05) and + math.isclose(pos1[0][1], pos2[0][1], rel_tol=1e-05, abs_tol=1e-05) and + math.isclose(pos1[0][2], pos2[0][2], rel_tol=1e-05, abs_tol=1e-05)) - grid_yz = gl.GLGridItem() - grid_yz.rotate(90, 0, 1, 0) - widget.addItem(grid_yz) +def build_nuggets_annotation_text(nugget) -> str: + return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" -def update_grid(gl_widget, points_to_display, color, annotation_text, size=3) -> (GLScatterPlotItem, GLTextItem): - scatter = GLScatterPlotItem(pos=points_to_display, color=color, size=size, pxMode=True) - annotation = GLTextItem(pos=[points_to_display[0][0], points_to_display[0][1], points_to_display[0][2]], - color=WHITE, - text=annotation_text, - font=EMBEDDING_ANNOTATION_FONT) - gl_widget.addItem(scatter) - gl_widget.addItem(annotation) - return scatter, annotation +class EmbeddingVisualizer: + def __init__(self, + attribute: Attribute = None, + nuggets: List[InformationNugget] = None, + currently_highlighted_nugget: InformationNugget = None, + best_guess: InformationNugget = None): + self._attribute: Attribute = attribute + self._nuggets: List[InformationNugget] = nuggets + self._currently_highlighted_nugget: InformationNugget = currently_highlighted_nugget + self._best_guess: InformationNugget = best_guess + self._nugget_to_displayed_items: Dict[InformationNugget, Tuple[GLScatterPlotItem, GLTextItem]] = dict() + self._gl_widget = GLViewWidget() + + def update_and_display_params(self, + attribute: Attribute, + nuggets: List[InformationNugget], + currently_highlighted_nugget: Union[InformationNugget, None], + best_guess: Union[InformationNugget, None]): + self.reset() -def build_annotation_text(nugget) -> str: - return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" + if attribute is not None: + self.display_attribute_embedding(attribute) + else: + logger.warning("Given attribute is null, can not display.") + + if nuggets: + self._nuggets = nuggets + self.display_nugget_embeddings(nuggets) + else: + logger.warning("Given nugget list is null or empty, can not display.") + + if best_guess is not None: + self.highlight_best_guess(best_guess) + else: + logger.info("Given best_guess is null, can not highlight.") + + self.highlight_confirmed_matches() + + if currently_highlighted_nugget is not None: + self.highlight_selected_nugget(currently_highlighted_nugget) + else: + logger.info("Given nugget to highlight is null, can not highlight.") + + def add_item_to_grid(self, + nugget_to_display_context: Tuple[Union[InformationNugget, Attribute], Color], + annotation_text: str, + size: int = DEFAULT_NUGGET_SIZE): + item_to_display, color = nugget_to_display_context + position = np.array([item_to_display[PCADimensionReducedTextEmbeddingSignal]]) if isinstance(item_to_display, InformationNugget) \ + else np.array([item_to_display[PCADimensionReducedLabelEmbeddingSignal]]) + + # Check for already existing scatter at the same position representing same nugget. This can happen due to usage of different extractors + for nugget, (scatter, _) in self._nugget_to_displayed_items.items(): + if positions_equal(scatter.pos, position) and nugget.text == item_to_display.text: + logger.info(f"{item_to_display} is already shown in the grid - probably it was extracted by multiple extractors - will not add again to grid.") + return + + scatter = GLScatterPlotItem(pos=position, color=color, size=size, pxMode=True) + annotation = GLTextItem(pos=[position[0][0], position[0][1], position[0][2]], + color=WHITE, + text=annotation_text, + font=EMBEDDING_ANNOTATION_FONT) + + self._gl_widget.addItem(scatter) + self._gl_widget.addItem(annotation) + + if isinstance(item_to_display, InformationNugget): + self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) + + return scatter, annotation + + def highlight_best_guess(self, best_guess: InformationNugget): + self._best_guess = best_guess + + if self._best_guess == self._currently_highlighted_nugget: + self._highlight_nugget(self._best_guess, BLUE, 15) + return + + self._highlight_nugget(self._best_guess, WHITE, 15) + def highlight_selected_nugget(self, newly_selected_nugget: InformationNugget): + (highlight_color, highlight_size), (reset_color, reset_size) = self._determine_update_values( + previously_selected_nugget=self._currently_highlighted_nugget, + newly_selected_nugget=newly_selected_nugget) -def add_other_best_guess(gl_widget, other_best_guess, nugget_to_displayed_items): - nugget_embedding: np.ndarray = np.array([other_best_guess[PCADimensionReducedTextEmbeddingSignal]]) - scatter, annotation = update_grid(gl_widget, nugget_embedding, YELLOW, - build_annotation_text(other_best_guess), 15) - nugget_to_displayed_items[other_best_guess] = (scatter, annotation) + self._highlight_nugget(nugget_to_highlight=newly_selected_nugget, + new_color=highlight_color, + new_size=highlight_size) + if self._currently_highlighted_nugget is not None: + currently_highlighted_scatter, _ = self._nugget_to_displayed_items[self._currently_highlighted_nugget] + currently_highlighted_scatter.setData(color=reset_color, size=reset_size) -def remove_nuggets_from_widget(nuggets_to_remove, nugget_to_displayed_items, gl_widget): - for nugget in nuggets_to_remove: - scatter, annotation = nugget_to_displayed_items.pop(nugget) + self._currently_highlighted_nugget = newly_selected_nugget - gl_widget.removeItem(scatter) - gl_widget.removeItem(annotation) + def display_other_best_guesses(self, other_best_guesses): + for other_best_guess in other_best_guesses: + self._add_other_best_guess(other_best_guess) + def remove_other_best_guesses(self, other_best_guesses): + self.remove_nuggets_from_widget(other_best_guesses) -def highlight_best_guess(best_guess, currently_highlighted_nugget, nugget_to_displayed_items): - if best_guess == currently_highlighted_nugget: - highlight_nugget(best_guess, nugget_to_displayed_items, BLUE, 15) - return + def display_nugget_embeddings(self, nuggets): + for nugget in nuggets: + nugget_to_display_context = (nugget, self._determine_nuggets_color(nugget)) - highlight_nugget(best_guess, nugget_to_displayed_items, WHITE, 15) + self.add_item_to_grid(nugget_to_display_context=nugget_to_display_context, + annotation_text=build_nuggets_annotation_text(nugget)) + def display_attribute_embedding(self, attribute): + self.add_item_to_grid(nugget_to_display_context=(attribute, RED), + annotation_text=attribute.name) + self._attribute = attribute # save for later use -def highlight_selected_nugget(nugget, currently_highlighted_nugget, best_guess, nugget_to_displayed_items): - (highlight_color, highlight_size), (reset_color, reset_size) = determine_update_values( - currently_highlighted_nugget, nugget, best_guess) + def reset(self): + for scatter, annotation in self._nugget_to_displayed_items.values(): + self._gl_widget.removeItem(scatter) + self._gl_widget.removeItem(annotation) - highlight_nugget(nugget, nugget_to_displayed_items, highlight_color, highlight_size) + self._nugget_to_displayed_items = {} + self._currently_highlighted_nugget = None + self._best_guess = None - if currently_highlighted_nugget is not None: - currently_highlighted_scatter, _ = nugget_to_displayed_items[currently_highlighted_nugget] - currently_highlighted_scatter.setData(color=reset_color, size=reset_size) + def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( + (int, Color), (int, Color)): + highlight_color = BLUE + highlight_size = 15 if newly_selected_nugget == self._best_guess else 10 + if previously_selected_nugget in self._attribute.confirmed_matches: + print("") -def highlight_nugget(nugget_to_highlight, nugget_to_displayed_items, new_color, new_size): - scatter_to_highlight, _ = nugget_to_displayed_items[nugget_to_highlight] + reset_color = WHITE if previously_selected_nugget == self._best_guess or previously_selected_nugget is None else self._determine_nuggets_color( + previously_selected_nugget) + reset_size = 15 if previously_selected_nugget == self._best_guess else 3 - if scatter_to_highlight is None: - logger.warning("Couldn't find nugget to highlight") - return + return (highlight_color, highlight_size), (reset_color, reset_size) - scatter_to_highlight.setData(color=new_color, size=new_size) + def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: + if (self._attribute is None or + CurrentThresholdSignal.identifier not in self._attribute.signals): + logger.warning(f"Could not determine nuggets color from given attribute: {self._attribute}. " + f"Will return purple as color highlighting nuggets with this issue.") + return PURPLE + return WHITE if nugget[CachedDistanceSignal] < self._attribute[CurrentThresholdSignal] else RED -def determine_update_values(previously_selected_nugget, newly_selected_nugget, best_guess) -> ( - (int, Color), (int, Color)): - highlight_color = BLUE - highlight_size = 15 if newly_selected_nugget == best_guess else 10 + def highlight_confirmed_matches(self): + for confirmed_match in self._attribute.confirmed_matches: + if confirmed_match in self._nugget_to_displayed_items: + self._highlight_nugget(confirmed_match, GREEN, DEFAULT_NUGGET_SIZE) - reset_color = WHITE if previously_selected_nugget == best_guess else GREEN - reset_size = 15 if previously_selected_nugget == best_guess else 3 + def _add_grids(self): + grid_xy = gl.GLGridItem() + self._gl_widget.addItem(grid_xy) + + grid_xz = gl.GLGridItem() + grid_xz.rotate(90, 1, 0, 0) + self._gl_widget.addItem(grid_xz) + + grid_yz = gl.GLGridItem() + grid_yz.rotate(90, 0, 1, 0) + self._gl_widget.addItem(grid_yz) + + def _highlight_nugget(self, nugget_to_highlight, new_color, new_size): + scatter_to_highlight, _ = self._nugget_to_displayed_items[nugget_to_highlight] + + if scatter_to_highlight is None: + logger.warning("Couldn't find nugget to highlight.") + return - return (highlight_color, highlight_size), (reset_color, reset_size) + scatter_to_highlight.setData(color=new_color, size=new_size) + def _add_other_best_guess(self, other_best_guess): + self.add_item_to_grid(nugget_to_display_context=(other_best_guess, YELLOW), + annotation_text=build_nuggets_annotation_text(other_best_guess), + size=15) -class EmbeddingVisualizerWindow(QMainWindow): - def __init__(self, attribute=None, nuggets=None, currently_highlighted_nugget=None, best_guess=None): - super(EmbeddingVisualizerWindow, self).__init__() + def remove_nuggets_from_widget(self, nuggets_to_remove): + for nugget in nuggets_to_remove: + scatter, annotation = self._nugget_to_displayed_items.pop(nugget) - self.nugget_to_displayed_items = {} - self.currently_highlighted_nugget = None - self.best_guess = best_guess + self._gl_widget.removeItem(scatter) + self._gl_widget.removeItem(annotation) + + +class EmbeddingVisualizerWindow(EmbeddingVisualizer, QMainWindow): + def __init__(self, + attribute=None, + nuggets=None, + currently_highlighted_nugget=None, + best_guess=None): + EmbeddingVisualizer.__init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess) + QMainWindow.__init__(self) self.setWindowTitle("3D Grid Visualizer") self.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) @@ -141,76 +262,34 @@ def __init__(self, attribute=None, nuggets=None, currently_highlighted_nugget=No self.fullscreen_layout = QVBoxLayout() central_widget.setLayout(self.fullscreen_layout) - self.fullscreen_gl_widget = GLViewWidget() - self.fullscreen_layout.addWidget(self.fullscreen_gl_widget) + self.fullscreen_layout.addWidget(self._gl_widget) - add_grids(self.fullscreen_gl_widget) + self._add_grids() if (attribute is not None and nuggets is not None and currently_highlighted_nugget is not None and best_guess is not None): - self.update_grid(attribute, nuggets, currently_highlighted_nugget, best_guess) + self.update_and_display_params(attribute, nuggets, currently_highlighted_nugget, best_guess) else: self.setVisible(False) def closeEvent(self, event): event.accept() - def copy_state(self, attribute, nuggets): - update_grid(self.fullscreen_gl_widget, - [attribute[PCADimensionReducedLabelEmbeddingSignal]], - RED, - attribute.name) - - for nugget in nuggets: - nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) - scatter, annotation = update_grid(self.fullscreen_gl_widget, nugget_embedding, GREEN, - build_annotation_text(nugget)) - self.nugget_to_displayed_items[nugget] = (scatter, annotation) - - def highlight_selected_nugget(self, nugget): - highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, - self.nugget_to_displayed_items) - - self.currently_highlighted_nugget = nugget - - def display_other_best_guesses(self, other_best_guesses): - for other_best_guess in other_best_guesses: - add_other_best_guess(self.fullscreen_gl_widget, other_best_guess, self.nugget_to_displayed_items) - def remove_other_best_guesses(self, other_best_guesses): - remove_nuggets_from_widget(other_best_guesses, self.nugget_to_displayed_items, self.fullscreen_gl_widget) - - def update_grid(self, attribute, nuggets, currently_highlighted_nugget, best_guess): - self.reset() - - self.copy_state(attribute, nuggets) - highlight_best_guess(best_guess, currently_highlighted_nugget, self.nugget_to_displayed_items) - self.highlight_selected_nugget(currently_highlighted_nugget) - - def reset(self): - for scatter, annotation in self.nugget_to_displayed_items.values(): - self.fullscreen_gl_widget.removeItem(scatter) - self.fullscreen_gl_widget.removeItem(annotation) - - self.nugget_to_displayed_items = {} - self.currently_highlighted_nugget = None - self.best_guess = None - - -class EmbeddingVisualizerWidget(QWidget): +class EmbeddingVisualizerWidget(EmbeddingVisualizer, QWidget): def __init__(self): - super(EmbeddingVisualizerWidget, self).__init__() + EmbeddingVisualizer.__init__(self) + QWidget.__init__(self) self.layout = QVBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) self.setLayout(self.layout) - self.gl_widget = GLViewWidget() - self.gl_widget.setMinimumHeight(300) # Set the initial height of the grid to 200 - self.layout.addWidget(self.gl_widget) + self._gl_widget.setMinimumHeight(300) # Set the initial height of the grid to 200 + self.layout.addWidget(self._gl_widget) self.best_guesses_widget = QWidget() self.best_guesses_widget_layout = QHBoxLayout(self.best_guesses_widget) @@ -220,99 +299,71 @@ def __init__(self): self.fullscreen_button.clicked.connect(self._show_embedding_visualizer_window) self.best_guesses_widget_layout.addWidget(self.fullscreen_button) self.show_other_best_guesses_button = QPushButton("Show best guesses from other documents") - self.show_other_best_guesses_button.clicked.connect(self._display_other_best_guesses) + self.show_other_best_guesses_button.clicked.connect(self._handle_show_other_best_guesses_clicked) self.best_guesses_widget_layout.addWidget(self.show_other_best_guesses_button) self.remove_other_best_guesses_button = QPushButton("Stop showing best guesses from other documents") self.remove_other_best_guesses_button.setEnabled(False) - self.remove_other_best_guesses_button.clicked.connect(self._remove_other_best_guesses) + self.remove_other_best_guesses_button.clicked.connect(self._handle_remove_other_best_guesses_clicked) self.best_guesses_widget_layout.addWidget(self.remove_other_best_guesses_button) self.layout.addWidget(self.best_guesses_widget) - add_grids(self.gl_widget) + self._add_grids() self.fullscreen_window = None - self.attribute = None - self.nugget_to_displayed_items = {} - self.currently_highlighted_nugget = None - self.best_guess = None self.other_best_guesses = None def _show_embedding_visualizer_window(self): if self.fullscreen_window is None: - self.fullscreen_window = EmbeddingVisualizerWindow(attribute=self.attribute, - nuggets=self.nugget_to_displayed_items.keys(), - currently_highlighted_nugget=self.currently_highlighted_nugget, - best_guess=self.best_guess) + self.fullscreen_window = EmbeddingVisualizerWindow(attribute=self._attribute, + nuggets=list(self._nugget_to_displayed_items.keys()), + currently_highlighted_nugget=self._currently_highlighted_nugget, + best_guess=self._best_guess) self.fullscreen_window.show() def return_from_embedding_visualizer_window(self): self.fullscreen_window.close() self.fullscreen_window = None - def display_attribute_embedding(self, attribute): - attribute_embedding = np.array([attribute[PCADimensionReducedLabelEmbeddingSignal]]) - update_grid(self.gl_widget, attribute_embedding, RED, attribute.name) - self.attribute = attribute # save for later use - - def display_nugget_embedding(self, nuggets): - for nugget in nuggets: - self._add_nugget_embedding(nugget) - - def _display_other_best_guesses(self): - for other_best_guess in self.other_best_guesses: - add_other_best_guess(self.gl_widget, other_best_guess, self.nugget_to_displayed_items) - - self.show_other_best_guesses_button.setEnabled(False) - self.remove_other_best_guesses_button.setEnabled(True) - - if self.fullscreen_window is not None: - self.fullscreen_window.display_other_best_guesses(self.other_best_guesses) - - def _remove_other_best_guesses(self): - remove_nuggets_from_widget(self.other_best_guesses, self.nugget_to_displayed_items, self.gl_widget) - - self.show_other_best_guesses_button.setEnabled(True) - self.remove_other_best_guesses_button.setEnabled(False) - - if self.fullscreen_window is not None: - self.fullscreen_window.remove_other_best_guesses(self.other_best_guesses) - def update_other_best_guesses(self, other_best_guesses): self.other_best_guesses = other_best_guesses - def _add_nugget_embedding(self, nugget): - nugget_embedding: np.ndarray = np.array([nugget[PCADimensionReducedTextEmbeddingSignal]]) - scatter, annotation = update_grid(self.gl_widget, nugget_embedding, GREEN, build_annotation_text(nugget)) - self.nugget_to_displayed_items[nugget] = (scatter, annotation) - def highlight_selected_nugget(self, nugget): - highlight_selected_nugget(nugget, self.currently_highlighted_nugget, self.best_guess, - self.nugget_to_displayed_items) + super().highlight_selected_nugget(nugget) if self.fullscreen_window is not None: self.fullscreen_window.highlight_selected_nugget(nugget) - self.currently_highlighted_nugget = nugget - - def highlight_best_guess(self, nugget): - self.best_guess = nugget + def highlight_best_guess(self, best_guess: InformationNugget): + super().highlight_best_guess(best_guess) - highlight_best_guess(nugget, self.currently_highlighted_nugget, self.nugget_to_displayed_items) + if self.fullscreen_window is not None: + self.fullscreen_window.highlight_best_guess(best_guess) def reset(self): - for scatter, annotation in self.nugget_to_displayed_items.values(): - self.gl_widget.removeItem(scatter) - self.gl_widget.removeItem(annotation) + super().reset() self.fullscreen_window = None - self.nugget_to_displayed_items = {} - self.currently_highlighted_nugget = None - self.best_guess = None self.other_best_guesses = None self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) + def _handle_show_other_best_guesses_clicked(self): + self.show_other_best_guesses_button.setEnabled(False) + self.remove_other_best_guesses_button.setEnabled(True) + + self.display_other_best_guesses(self.other_best_guesses) + if self.fullscreen_window is not None: + self.fullscreen_window.display_other_best_guesses(self.other_best_guesses) + + def _handle_remove_other_best_guesses_clicked(self): + self.show_other_best_guesses_button.setEnabled(True) + self.remove_other_best_guesses_button.setEnabled(False) + + self.remove_nuggets_from_widget(self.other_best_guesses) + if self.fullscreen_window is not None: + self.fullscreen_window.remove_nuggets_from_widget(self.other_best_guesses) + class BarChartVisualizerWidget(QWidget): def __init__(self, parent=None): @@ -362,13 +413,13 @@ def plot_bar_chart(self): height = rect.get_height() ax.text( rect.get_x() + rect.get_width() / 2, - height/2, + height / 2, f'{texts[idx]}', ha='center', va='center', rotation=90, # Rotate text by 90 degrees fontsize=12, - color='white'# fontcolors[idx]# Optional: Adjust font size + color='white' # fontcolors[idx]# Optional: Adjust font size ) self.bar_chart_canvas = FigureCanvas(fig) @@ -422,6 +473,7 @@ def on_pick(self, event): self.annotation.set_visible(True) self.current_annotation_index = index self.bar_chart_canvas.draw_idle() + def clear_data(self): self.data = [] self.bar = None From 1fce6c24406711f5a5731124b2226638e747a53e Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 27 Jul 2024 12:31:51 +0200 Subject: [PATCH 31/85] fix several grid related errors --- wannadb_ui/interactive_matching.py | 30 ++--- wannadb_ui/visualizations.py | 173 +++++++++++++++++++---------- 2 files changed, 121 insertions(+), 82 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 985464b6..088e18d7 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -137,11 +137,12 @@ def update_nuggets(self, feedback_request): self.description.setText( "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") - self.current_threshold_label.setText(f"Current Threshold: {current_threshold}") + self.current_threshold_label.setText(f"Current Threshold: {round(current_threshold, 4)}") params = { "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), - "max_distance": current_threshold + "max_distance": current_threshold, + "other_best_guesses": feedback_nuggets } self.suggestion_visualizer_button.setVisible(True) @@ -149,12 +150,12 @@ def update_nuggets(self, feedback_request): self._process_likely_nuggets_label(feedback_nuggets, current_threshold) self.nugget_list.update_item_list(feedback_nuggets, params) - self.update_other_best_guesses(feedback_nuggets) if len(feedback_nuggets) > 0: self.suggestion_visualizer.update_and_display_params(attribute=attribute, nuggets=feedback_nuggets + all_guessed_nugget_matches, currently_highlighted_nugget=None, - best_guess=feedback_nuggets[0]) + best_guess=feedback_nuggets[0], + other_best_guesses=feedback_nuggets) if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText( @@ -182,10 +183,6 @@ def enable_input(self): def disable_input(self): self.nugget_list.disable_input() - def update_other_best_guesses(self, other_best_guesses): - for item_widget in self.nugget_list.item_widgets: - item_widget.update_other_best_guesses(other_best_guesses) - def _show_suggestion_visualizer(self): self.suggestion_visualizer.setVisible(True) @@ -254,6 +251,8 @@ def update_item(self, item, params=None): max_start_chars = params["max_start_chars"] max_distance = params["max_distance"] + self.other_best_guesses = [other_best_guess for other_best_guess in params["other_best_guesses"] + if other_best_guess != self.nugget] sentence = self.nugget[CachedContextSentenceSignal]["text"] start_char = self.nugget[CachedContextSentenceSignal]["start_char"] @@ -285,10 +284,6 @@ def update_item(self, item, params=None): # self.info_button.setText(f"{str(round(self.nugget[CachedDistanceSignal], 2)).ljust(4)}") - def update_other_best_guesses(self, other_best_guesses): - self.other_best_guesses = [other_best_guess for other_best_guess in other_best_guesses if - other_best_guess != self.nugget] - def _match_button_clicked(self): self.nugget_list_widget.interactive_matching_widget.main_window.give_feedback_task({ "message": "is-match", @@ -568,7 +563,8 @@ def update_document(self, nugget, other_best_guesses): self.visualizer.update_and_display_params(attribute=self.current_attribute, nuggets=self.document.nuggets, currently_highlighted_nugget=nugget, - best_guess=self.nuggets_sorted_by_distance[0]) + best_guess=self.nuggets_sorted_by_distance[0], + other_best_guesses=other_best_guesses) else: self.idx_mapper = {} for idx in range(len(self.document.text)): @@ -608,14 +604,6 @@ def disable_input(self): def update_attribute(self, attribute): self.current_attribute = attribute - def update_nuggets(self, nuggets, other_best_guesses): - if len(nuggets) == 0: - return - - self.visualizer.reset() - self.visualizer.display_nugget_embeddings(nuggets) - self.visualizer.update_other_best_guesses(other_best_guesses) - def _highlight_best_guess(self, best_guess): if best_guess is None: return diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 876c710d..291f3072 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -65,19 +65,23 @@ def __init__(self, attribute: Attribute = None, nuggets: List[InformationNugget] = None, currently_highlighted_nugget: InformationNugget = None, - best_guess: InformationNugget = None): + best_guess: InformationNugget = None, + other_best_guesses: List[InformationNugget] = None): self._attribute: Attribute = attribute self._nuggets: List[InformationNugget] = nuggets self._currently_highlighted_nugget: InformationNugget = currently_highlighted_nugget self._best_guess: InformationNugget = best_guess + self._other_best_guesses: List[InformationNugget] = other_best_guesses self._nugget_to_displayed_items: Dict[InformationNugget, Tuple[GLScatterPlotItem, GLTextItem]] = dict() + self._nugget_to_similar_nugget: Dict[InformationNugget, Union[InformationNugget, None]] = dict() self._gl_widget = GLViewWidget() def update_and_display_params(self, attribute: Attribute, nuggets: List[InformationNugget], currently_highlighted_nugget: Union[InformationNugget, None], - best_guess: Union[InformationNugget, None]): + best_guess: Union[InformationNugget, None], + other_best_guesses: List[InformationNugget]): self.reset() if attribute is not None: @@ -103,18 +107,26 @@ def update_and_display_params(self, else: logger.info("Given nugget to highlight is null, can not highlight.") + self._other_best_guesses = other_best_guesses + def add_item_to_grid(self, nugget_to_display_context: Tuple[Union[InformationNugget, Attribute], Color], annotation_text: str, size: int = DEFAULT_NUGGET_SIZE): item_to_display, color = nugget_to_display_context - position = np.array([item_to_display[PCADimensionReducedTextEmbeddingSignal]]) if isinstance(item_to_display, InformationNugget) \ + position = np.array([item_to_display[PCADimensionReducedTextEmbeddingSignal]]) if isinstance(item_to_display, + InformationNugget) \ else np.array([item_to_display[PCADimensionReducedLabelEmbeddingSignal]]) - # Check for already existing scatter at the same position representing same nugget. This can happen due to usage of different extractors - for nugget, (scatter, _) in self._nugget_to_displayed_items.items(): + # Check for already existing scatter at the same position representing same nugget. + # This can happen due to usage of different extractors. + for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): if positions_equal(scatter.pos, position) and nugget.text == item_to_display.text: - logger.info(f"{item_to_display} is already shown in the grid - probably it was extracted by multiple extractors - will not add again to grid.") + logger.info( + f"{item_to_display} is already shown in the grid - probably it was extracted by multiple extractors" + f" - will not add again to grid.") + self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) + self._nugget_to_similar_nugget[item_to_display] = nugget return scatter = GLScatterPlotItem(pos=position, color=color, size=size, pxMode=True) @@ -128,6 +140,7 @@ def add_item_to_grid(self, if isinstance(item_to_display, InformationNugget): self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) + self._nugget_to_similar_nugget[item_to_display] = None return scatter, annotation @@ -145,21 +158,21 @@ def highlight_selected_nugget(self, newly_selected_nugget: InformationNugget): previously_selected_nugget=self._currently_highlighted_nugget, newly_selected_nugget=newly_selected_nugget) - self._highlight_nugget(nugget_to_highlight=newly_selected_nugget, - new_color=highlight_color, - new_size=highlight_size) - if self._currently_highlighted_nugget is not None: currently_highlighted_scatter, _ = self._nugget_to_displayed_items[self._currently_highlighted_nugget] currently_highlighted_scatter.setData(color=reset_color, size=reset_size) + self._highlight_nugget(nugget_to_highlight=newly_selected_nugget, + new_color=highlight_color, + new_size=highlight_size) + self._currently_highlighted_nugget = newly_selected_nugget - def display_other_best_guesses(self, other_best_guesses): + def display_other_best_guesses(self, other_best_guesses: List[InformationNugget]): for other_best_guess in other_best_guesses: self._add_other_best_guess(other_best_guess) - def remove_other_best_guesses(self, other_best_guesses): + def remove_other_best_guesses(self, other_best_guesses: List[InformationNugget]): self.remove_nuggets_from_widget(other_best_guesses) def display_nugget_embeddings(self, nuggets): @@ -174,26 +187,64 @@ def display_attribute_embedding(self, attribute): annotation_text=attribute.name) self._attribute = attribute # save for later use + def remove_nuggets_from_widget(self, nuggets_to_remove): + for nugget in nuggets_to_remove: + scatter, annotation = self._nugget_to_displayed_items.pop(nugget) + + if nugget in self._nugget_to_similar_nugget and self._nugget_to_similar_nugget[nugget] is not None: + # This nugget is represented by same items as another nugget. + # Once this other nugget is processed, the corresponding items will be removed from grid + continue + + self._gl_widget.removeItem(scatter) + self._gl_widget.removeItem(annotation) + + def highlight_confirmed_matches(self): + if self._attribute is None: + logger.warning("Attribute has not been initialized yet, can not highlight confirmed matches.") + return + + for confirmed_match in self._attribute.confirmed_matches: + if confirmed_match in self._nugget_to_displayed_items: + self._highlight_nugget(confirmed_match, GREEN, DEFAULT_NUGGET_SIZE) + def reset(self): - for scatter, annotation in self._nugget_to_displayed_items.values(): + for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): + if nugget in self._nugget_to_similar_nugget and self._nugget_to_similar_nugget[nugget] is not None: + # Corresponding items will be removed once processing similar nugget + continue self._gl_widget.removeItem(scatter) self._gl_widget.removeItem(annotation) self._nugget_to_displayed_items = {} + self._nugget_to_similar_nugget = {} self._currently_highlighted_nugget = None self._best_guess = None def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( (int, Color), (int, Color)): - highlight_color = BLUE - highlight_size = 15 if newly_selected_nugget == self._best_guess else 10 + similar_prev_selected_nugget = self._nugget_to_similar_nugget[previously_selected_nugget] \ + if previously_selected_nugget in self._nugget_to_similar_nugget else None + similar_newly_selected_nugget = self._nugget_to_similar_nugget[newly_selected_nugget] \ + if previously_selected_nugget in self._nugget_to_similar_nugget else None - if previously_selected_nugget in self._attribute.confirmed_matches: - print("") - - reset_color = WHITE if previously_selected_nugget == self._best_guess or previously_selected_nugget is None else self._determine_nuggets_color( - previously_selected_nugget) - reset_size = 15 if previously_selected_nugget == self._best_guess else 3 + highlight_color = BLUE + highlight_size = 15 if newly_selected_nugget == self._best_guess or similar_newly_selected_nugget == self._best_guess \ + else 10 + + if previously_selected_nugget is None: + reset_color = WHITE + reset_size = DEFAULT_NUGGET_SIZE + elif (previously_selected_nugget in self._attribute.confirmed_matches or + similar_prev_selected_nugget in self._attribute.confirmed_matches): + reset_color = GREEN + reset_size = DEFAULT_NUGGET_SIZE + elif previously_selected_nugget == self._best_guess or similar_prev_selected_nugget == self._best_guess: + reset_color = WHITE + reset_size = 15 + else: + reset_color = self._determine_nuggets_color(previously_selected_nugget) + reset_size = DEFAULT_NUGGET_SIZE return (highlight_color, highlight_size), (reset_color, reset_size) @@ -204,12 +255,12 @@ def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: f"Will return purple as color highlighting nuggets with this issue.") return PURPLE - return WHITE if nugget[CachedDistanceSignal] < self._attribute[CurrentThresholdSignal] else RED + similar_nugget = self._nugget_to_similar_nugget[nugget] if nugget in self._nugget_to_similar_nugget else None - def highlight_confirmed_matches(self): - for confirmed_match in self._attribute.confirmed_matches: - if confirmed_match in self._nugget_to_displayed_items: - self._highlight_nugget(confirmed_match, GREEN, DEFAULT_NUGGET_SIZE) + return (WHITE if nugget[CachedDistanceSignal] < self._attribute[CurrentThresholdSignal] or + (similar_nugget is not None and similar_nugget[CachedDistanceSignal] < self._attribute[ + CurrentThresholdSignal]) + else RED) def _add_grids(self): grid_xy = gl.GLGridItem() @@ -237,20 +288,14 @@ def _add_other_best_guess(self, other_best_guess): annotation_text=build_nuggets_annotation_text(other_best_guess), size=15) - def remove_nuggets_from_widget(self, nuggets_to_remove): - for nugget in nuggets_to_remove: - scatter, annotation = self._nugget_to_displayed_items.pop(nugget) - - self._gl_widget.removeItem(scatter) - self._gl_widget.removeItem(annotation) - class EmbeddingVisualizerWindow(EmbeddingVisualizer, QMainWindow): def __init__(self, - attribute=None, - nuggets=None, - currently_highlighted_nugget=None, - best_guess=None): + attribute: Attribute = None, + nuggets: List[InformationNugget] = None, + currently_highlighted_nugget: InformationNugget = None, + best_guess: InformationNugget = None, + other_best_guesses: List[InformationNugget] = None): EmbeddingVisualizer.__init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess) QMainWindow.__init__(self) @@ -270,7 +315,8 @@ def __init__(self, nuggets is not None and currently_highlighted_nugget is not None and best_guess is not None): - self.update_and_display_params(attribute, nuggets, currently_highlighted_nugget, best_guess) + self.update_and_display_params(attribute, nuggets, currently_highlighted_nugget, best_guess, + other_best_guesses) else: self.setVisible(False) @@ -309,60 +355,65 @@ def __init__(self): self._add_grids() - self.fullscreen_window = None - self.other_best_guesses = None + self._fullscreen_window = None + self._other_best_guesses = None def _show_embedding_visualizer_window(self): - if self.fullscreen_window is None: - self.fullscreen_window = EmbeddingVisualizerWindow(attribute=self._attribute, - nuggets=list(self._nugget_to_displayed_items.keys()), - currently_highlighted_nugget=self._currently_highlighted_nugget, - best_guess=self._best_guess) - self.fullscreen_window.show() + if self._fullscreen_window is None: + self._fullscreen_window = EmbeddingVisualizerWindow(attribute=self._attribute, + nuggets=list(self._nugget_to_displayed_items.keys()), + currently_highlighted_nugget=self._currently_highlighted_nugget, + best_guess=self._best_guess) + self._fullscreen_window.show() def return_from_embedding_visualizer_window(self): - self.fullscreen_window.close() - self.fullscreen_window = None + self._fullscreen_window.close() + self._fullscreen_window = None def update_other_best_guesses(self, other_best_guesses): - self.other_best_guesses = other_best_guesses + self._other_best_guesses = other_best_guesses def highlight_selected_nugget(self, nugget): super().highlight_selected_nugget(nugget) - if self.fullscreen_window is not None: - self.fullscreen_window.highlight_selected_nugget(nugget) + if self._fullscreen_window is not None: + self._fullscreen_window.highlight_selected_nugget(nugget) def highlight_best_guess(self, best_guess: InformationNugget): super().highlight_best_guess(best_guess) - if self.fullscreen_window is not None: - self.fullscreen_window.highlight_best_guess(best_guess) + if self._fullscreen_window is not None: + self._fullscreen_window.highlight_best_guess(best_guess) def reset(self): super().reset() - self.fullscreen_window = None - self.other_best_guesses = None + self._fullscreen_window = None + self._other_best_guesses = None self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) def _handle_show_other_best_guesses_clicked(self): + if self._other_best_guesses is None: + logger.warning("Can not display best guesses from other documents as these best guesses have not been " + "initialized yet.") + return + self.show_other_best_guesses_button.setEnabled(False) self.remove_other_best_guesses_button.setEnabled(True) - self.display_other_best_guesses(self.other_best_guesses) - if self.fullscreen_window is not None: - self.fullscreen_window.display_other_best_guesses(self.other_best_guesses) + self.display_other_best_guesses(self._other_best_guesses) + if self._fullscreen_window is not None: + self._fullscreen_window.display_other_best_guesses(self._other_best_guesses) def _handle_remove_other_best_guesses_clicked(self): self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) - self.remove_nuggets_from_widget(self.other_best_guesses) - if self.fullscreen_window is not None: - self.fullscreen_window.remove_nuggets_from_widget(self.other_best_guesses) + self.remove_nuggets_from_widget(self._other_best_guesses) + if self._fullscreen_window is not None: + self._fullscreen_window.remove_nuggets_from_widget(self._other_best_guesses) class BarChartVisualizerWidget(QWidget): From ad3963895332e6b344c70bec2f9f79d9404472d8 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Tue, 30 Jul 2024 01:04:07 +0200 Subject: [PATCH 32/85] improve visualizations in document overview: threshold label displays its changes, highlight newly added list items, add tooltip for newly added items --- wannadb/matching/matching.py | 11 +++- wannadb_ui/interactive_matching.py | 100 +++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 27 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 9b9acb2c..3d2e88bc 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -131,6 +131,7 @@ def _call( logger.info(f"Matching attribute '{attribute.name}'.") start_matching: float = time.time() self._max_distance = self._default_max_distance + self._max_distance_change = 0 attribute[CurrentThresholdSignal] = CurrentThresholdSignal(self._max_distance) statistics[attribute.name]["max_distances"] = [self._max_distance] statistics[attribute.name]["feedback_durations"] = [] @@ -180,6 +181,7 @@ def _sort_remaining_documents(): tik: float = time.time() num_feedback: int = 0 continue_matching: bool = True + old_feedback_nuggets: List[InformationNugget] = [] while continue_matching and num_feedback < self._max_num_feedback and remaining_documents != []: # sort remaining documents by distance _sort_remaining_documents() @@ -269,17 +271,22 @@ def _sort_remaining_documents(): self.identifier, { "max-distance": self._max_distance, + "max-distance-change": self._max_distance_change, "nuggets": feedback_nuggets, + "new-nuggets": [nugget for nugget in feedback_nuggets if nugget not in old_feedback_nuggets], "all-guessed-nugget-matches": all_guessed_nugget_matches, "attribute": attribute, "num-feedback": num_feedback, "num-nuggets-above": num_nuggets_above, - "num-nuggets-below": num_nuggets_below + "num-nuggets-below": num_nuggets_below, + "sampling-mode": self._sampling_mode } ) t1 = time.time() statistics[attribute.name]["feedback_durations"].append(t1 - t0) + old_feedback_nuggets = feedback_nuggets + if feedback_result["message"] == "stop-interactive-matching": statistics[attribute.name]["stopped_matching_by_hand"] = True continue_matching = False @@ -312,6 +319,7 @@ def _sort_remaining_documents(): if feedback_nuggets_old_cached_distances[ix] < self._max_distance: min_dist = min(min_dist, feedback_nuggets[ix][CachedDistanceSignal]) if min_dist < self._max_distance: + self._max_distance_change = min_dist - self._max_distance self._max_distance = min_dist attribute[CurrentThresholdSignal] = CurrentThresholdSignal(min_dist) statistics[attribute.name]["max_distances"].append(min_dist) @@ -483,6 +491,7 @@ def run_nugget_pipeline(nuggets): if feedback_nuggets_old_cached_distances[ix] > self._max_distance: max_dist = max(max_dist, feedback_nuggets[ix][CachedDistanceSignal]) if max_dist > self._max_distance: + self._max_distance_change = max_dist - self._max_distance self._max_distance = max_dist attribute[CurrentThresholdSignal] = CurrentThresholdSignal(max_dist) statistics[attribute.name]["max_distances"].append(max_dist) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 088e18d7..2166eda2 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -5,13 +5,13 @@ from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy, \ - QBoxLayout + QSpacerItem from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ - CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, SUBHEADER_FONT + CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ EmbeddingVisualizerWindow @@ -87,18 +87,27 @@ def __init__(self, interactive_matching_widget): self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(10) - self.current_threshold_label = QLabel() - self.current_threshold_label.setFont(SUBHEADER_FONT) - self.layout.addWidget(self.current_threshold_label) + self.threshold_label = QLabel() + self.threshold_label.setFont(LABEL_FONT) + self.threshold_label.setText("Current Threshold: ") + self.threshold_label.setVisible(False) + self.threshold_value_label = QLabel() + self.threshold_value_label.setFont(LABEL_FONT) + self.threshold_change_label = QLabel() + self.threshold_change_label.setFont(LABEL_FONT) + self.threshold_hbox = QHBoxLayout() + self.threshold_hbox.setContentsMargins(0, 0, 0, 0) + self.threshold_hbox.setSpacing(0) + self.threshold_hbox.addWidget(self.threshold_label) + self.threshold_hbox.addWidget(self.threshold_value_label) + self.threshold_hbox.addWidget(self.threshold_change_label) + self.threshold_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + self.layout.addLayout(self.threshold_hbox) self.description = QLabel("Please wait while WannaDB prepares the interactive table population.") self.description.setFont(LABEL_FONT) self.layout.addWidget(self.description) - self.likely_nuggets = QLabel("") - self.likely_nuggets.setFont(LABEL_FONT) - self.layout.addWidget(self.likely_nuggets) - # suggestion visualizer self.suggestion_visualizer = EmbeddingVisualizerWindow() self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") @@ -133,22 +142,23 @@ def update_nuggets(self, feedback_request): all_guessed_nugget_matches = feedback_request["all-guessed-nugget-matches"] attribute = feedback_request["attribute"] current_threshold = feedback_request["max-distance"] + threshold_change = feedback_request["max-distance-change"] self.description.setText( "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") - self.current_threshold_label.setText(f"Current Threshold: {round(current_threshold, 4)}") + self._update_threshold_value_label(current_threshold, threshold_change) params = { "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), "max_distance": current_threshold, - "other_best_guesses": feedback_nuggets + "other_best_guesses": feedback_nuggets, + "new-nuggets": feedback_request["new-nuggets"], + "num-feedback": feedback_request["num-feedback"] } self.suggestion_visualizer_button.setVisible(True) - self._process_likely_nuggets_label(feedback_nuggets, current_threshold) - self.nugget_list.update_item_list(feedback_nuggets, params) if len(feedback_nuggets) > 0: self.suggestion_visualizer.update_and_display_params(attribute=attribute, @@ -168,15 +178,6 @@ def update_nuggets(self, feedback_request): else: self.num_nuggets_below_label.setText("") - def _process_likely_nuggets_label(self, nuggets, current_threshold): - TOP_NUGGETS = 5 - nuggets_to_add = [nugget for nugget in nuggets if current_threshold < nugget[CachedDistanceSignal]] - if nuggets_to_add: - top_nuggets = ', '.join(map(str, nuggets_to_add[:TOP_NUGGETS])) - self.likely_nuggets.setText(f"Based upon your last choice, the top most likely choices are {top_nuggets}.") - else: - self.likely_nuggets.setText("") - def enable_input(self): self.nugget_list.enable_input() @@ -186,6 +187,18 @@ def disable_input(self): def _show_suggestion_visualizer(self): self.suggestion_visualizer.setVisible(True) + def _update_threshold_value_label(self, new_threshold_value, threshold_value_change): + if round(threshold_value_change, 4) != 0: + self.threshold_value_label.setStyleSheet("color: yellow;") + change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' + self.threshold_change_label.setText(change_text) + else: + self.threshold_value_label.setStyleSheet("") + self.threshold_change_label.setText("") + + self.threshold_value_label.setText(f"{round(new_threshold_value, 4)} ") + self.threshold_label.setVisible(True) + class NuggetListItemWidget(CustomScrollableListItem): def __init__(self, nugget_list_widget): @@ -193,10 +206,11 @@ def __init__(self, nugget_list_widget): self.nugget_list_widget = nugget_list_widget self.nugget = None self.other_best_guesses = None + self._default_style_sheet = "QWidget#nuggetListItemWidget { background-color: white}" self.setFixedHeight(45) self.setObjectName("nuggetListItemWidget") - self.setStyleSheet("QWidget#nuggetListItemWidget { background-color: white}") + self.setStyleSheet(self._default_style_sheet) self.layout = QHBoxLayout(self) self.layout.setContentsMargins(20, 0, 20, 0) @@ -261,12 +275,23 @@ def update_item(self, item, params=None): if max_distance < self.nugget[CachedDistanceSignal]: color = LIGHT_YELLOW self.confidence_button.setIcon(ICON_LOW_CONFIDENCE) - self.confidence_button.setToolTip("Low confidence in this match, will not be included in result.") + self.confidence_button.setToolTip( + f"Low confidence in this match (Distance: {round(self.nugget[CachedDistanceSignal], 4)}), " + f"will not be included in result.") else: color = YELLOW self.confidence_button.setIcon(ICON_HIGH_CONFIDENCE) - self.confidence_button.setToolTip("High confidence in this match, will be included in result.") - self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") + self.confidence_button.setToolTip( + f"High confidence in this match (Distance: {round(self.nugget[CachedDistanceSignal], 4)}), " + f"will be included in result.") + + if self.nugget in params["new-nuggets"]: + self._handle_item_is_new(params["num-feedback"] == 1) + else: + self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") + self.setStyleSheet(self._default_style_sheet) + self.setToolTip("") + self.text_edit.setText("") formatted_text = ( @@ -319,6 +344,29 @@ def disable_input(self): self.match_button.setDisabled(True) self.fix_button.setDisabled(True) + def _handle_item_is_new(self, initial_selection): + old_threshold = -1 + new_threshold = -1 + old_distance = -1 + new_distance = -1 + + if initial_selection: + tooltip_text = (f'Item was added to the list as it belongs to the initial selection of items for the list. ' + 'The initial distance of its best match is within the considered range around the threshold.') + + else: + tooltip_text = ( + f'Item was newly added to the list as the distance of its best match is now within the considered ' + f'range around the current threshold.\n' + f'Old Threshold: {old_threshold} -> New Threshold: {new_threshold}\n' + f'Old Distance: {old_distance} -> New Distance: {new_distance}') + + self.setToolTip(tooltip_text) + + self.text_edit.setStyleSheet(f"color: black; background-color: {'#e7ffe6'}") + self.setStyleSheet(f"QFrame {{ background-color: {'#e7ffe6'}; }}\n" + f"QToolTip {{ background-color: {WHITE}; }}") + class DocumentWidget(QWidget): def __init__(self, interactive_matching_widget): From 32e34ea2c8632d7d08501560e030157f88fc840d Mon Sep 17 00:00:00 2001 From: nils-bz Date: Tue, 30 Jul 2024 18:52:35 +0200 Subject: [PATCH 33/85] implement first version of list visualizing changed best matches --- wannadb/matching/matching.py | 15 +++ wannadb_ui/common.py | 19 ++++ wannadb_ui/interactive_matching.py | 173 ++++++++++++++++++++--------- 3 files changed, 155 insertions(+), 52 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 3d2e88bc..e8ec6832 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -15,6 +15,7 @@ from wannadb.matching.distance import BaseDistance from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback +from wannadb_ui.common import BestMatchUpdate logger: logging.Logger = logging.getLogger(__name__) @@ -182,6 +183,8 @@ def _sort_remaining_documents(): num_feedback: int = 0 continue_matching: bool = True old_feedback_nuggets: List[InformationNugget] = [] + new_best_matches: Counter[str] = Counter[str]() + new_to_old_match: Dict[str, str] = {} while continue_matching and num_feedback < self._max_num_feedback and remaining_documents != []: # sort remaining documents by distance _sort_remaining_documents() @@ -274,6 +277,10 @@ def _sort_remaining_documents(): "max-distance-change": self._max_distance_change, "nuggets": feedback_nuggets, "new-nuggets": [nugget for nugget in feedback_nuggets if nugget not in old_feedback_nuggets], + "new-best-matches": [BestMatchUpdate(new_to_old_match[new_best_match], + new_best_match, + new_best_matches[new_best_match]) + for new_best_match in new_best_matches.keys()], "all-guessed-nugget-matches": all_guessed_nugget_matches, "attribute": attribute, "num-feedback": num_feedback, @@ -286,6 +293,8 @@ def _sort_remaining_documents(): statistics[attribute.name]["feedback_durations"].append(t1 - t0) old_feedback_nuggets = feedback_nuggets + new_best_matches.clear() + new_to_old_match.clear() if feedback_result["message"] == "stop-interactive-matching": statistics[attribute.name]["stopped_matching_by_hand"] = True @@ -386,6 +395,9 @@ def run_nugget_pipeline(nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: document[CurrentMatchIndexSignal] = ix + if nugget.text != current_guess.text: + new_best_matches.update([nugget.text]) + new_to_old_match[nugget.text] = current_guess.text distances_based_on_label = False # Find more nuggets that are similar to this match @@ -470,6 +482,9 @@ def run_nugget_pipeline(nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: document[CurrentMatchIndexSignal] = ix + if nugget.text != current_guess.text: + new_best_matches.update([nugget.text]) + new_to_old_match[nugget.text] = current_guess.text distances_based_on_label = False if self._adjust_threshold: diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 622d69eb..f8646c6e 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -210,3 +210,22 @@ def show_confirmation_dialog(parent, title_text, explanation_text, accept_text, no_button.setFocus() return dialog.exec() + + +class BestMatchUpdate: + def __init__(self, old_best_match, new_best_match, count): + self._old_best_match = old_best_match + self._new_best_match = new_best_match + self._count = count + + @property + def old_best_match(self): + return self._old_best_match + + @property + def new_best_match(self): + return self._new_best_match + + @property + def count(self): + return self._count diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 2166eda2..54eb912f 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -1,4 +1,7 @@ import logging +from collections import Counter +import random +from typing import List import numpy as np from PyQt6 import QtGui @@ -11,7 +14,8 @@ PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ - CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW + CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, LABEL_FONT_BOLD, SUBHEADER_FONT, \ + BestMatchUpdate from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ EmbeddingVisualizerWindow @@ -87,39 +91,14 @@ def __init__(self, interactive_matching_widget): self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(10) - self.threshold_label = QLabel() - self.threshold_label.setFont(LABEL_FONT) - self.threshold_label.setText("Current Threshold: ") - self.threshold_label.setVisible(False) - self.threshold_value_label = QLabel() - self.threshold_value_label.setFont(LABEL_FONT) - self.threshold_change_label = QLabel() - self.threshold_change_label.setFont(LABEL_FONT) - self.threshold_hbox = QHBoxLayout() - self.threshold_hbox.setContentsMargins(0, 0, 0, 0) - self.threshold_hbox.setSpacing(0) - self.threshold_hbox.addWidget(self.threshold_label) - self.threshold_hbox.addWidget(self.threshold_value_label) - self.threshold_hbox.addWidget(self.threshold_change_label) - self.threshold_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) - self.layout.addLayout(self.threshold_hbox) - self.description = QLabel("Please wait while WannaDB prepares the interactive table population.") self.description.setFont(LABEL_FONT) self.layout.addWidget(self.description) # suggestion visualizer - self.suggestion_visualizer = EmbeddingVisualizerWindow() - self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") - self.suggestion_visualizer_button.setFont(BUTTON_FONT) - self.suggestion_visualizer_button.setMaximumWidth(240) - self.suggestion_visualizer_button.setVisible(False) - self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) - self.suggestion_visualizer_layout = QHBoxLayout() - self.suggestion_visualizer_layout.setContentsMargins(0, 0, 0, 0) - self.suggestion_visualizer_layout.setSpacing(10) - self.suggestion_visualizer_layout.addWidget(self.suggestion_visualizer_button, 0, Qt.AlignmentFlag.AlignRight) - self.layout.addLayout(self.suggestion_visualizer_layout) + self.visualize_area = VisualizationArea() + self.layout.addWidget(self.visualize_area) + self.visualize_area.setVisible(False) # nugget list self.num_nuggets_above_label = QLabel("") @@ -144,10 +123,12 @@ def update_nuggets(self, feedback_request): current_threshold = feedback_request["max-distance"] threshold_change = feedback_request["max-distance-change"] + self.description.setText( "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") - self._update_threshold_value_label(current_threshold, threshold_change) + self.visualize_area.update_threshold_value_label(current_threshold, threshold_change) + self.visualize_area.update_best_match_list(feedback_request["new-best-matches"]) params = { "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), @@ -157,15 +138,15 @@ def update_nuggets(self, feedback_request): "num-feedback": feedback_request["num-feedback"] } - self.suggestion_visualizer_button.setVisible(True) + self.visualize_area.setVisible(True) self.nugget_list.update_item_list(feedback_nuggets, params) if len(feedback_nuggets) > 0: - self.suggestion_visualizer.update_and_display_params(attribute=attribute, - nuggets=feedback_nuggets + all_guessed_nugget_matches, - currently_highlighted_nugget=None, - best_guess=feedback_nuggets[0], - other_best_guesses=feedback_nuggets) + self.visualize_area.suggestion_visualizer.update_and_display_params(attribute=attribute, + nuggets=feedback_nuggets + all_guessed_nugget_matches, + currently_highlighted_nugget=None, + best_guess=feedback_nuggets[0], + other_best_guesses=feedback_nuggets) if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText( @@ -184,21 +165,6 @@ def enable_input(self): def disable_input(self): self.nugget_list.disable_input() - def _show_suggestion_visualizer(self): - self.suggestion_visualizer.setVisible(True) - - def _update_threshold_value_label(self, new_threshold_value, threshold_value_change): - if round(threshold_value_change, 4) != 0: - self.threshold_value_label.setStyleSheet("color: yellow;") - change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' - self.threshold_change_label.setText(change_text) - else: - self.threshold_value_label.setStyleSheet("") - self.threshold_change_label.setText("") - - self.threshold_value_label.setText(f"{round(new_threshold_value, 4)} ") - self.threshold_label.setVisible(True) - class NuggetListItemWidget(CustomScrollableListItem): def __init__(self, nugget_list_widget): @@ -292,7 +258,6 @@ def update_item(self, item, params=None): self.setStyleSheet(self._default_style_sheet) self.setToolTip("") - self.text_edit.setText("") formatted_text = ( f"{' ' * (max_start_chars - start_char)}{sentence[:start_char]}" @@ -740,3 +705,107 @@ def enable_input(self): def disable_input(self): pass + + +class VisualizationArea(QWidget): + def __init__(self): + super(VisualizationArea, self).__init__() + + self.layout = QVBoxLayout(self) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + self.title_label = QLabel("Data Insights") + self.title_label.setFont(SUBHEADER_FONT) + self.title_label.setContentsMargins(0, 5, 0, 5) + self.layout.addWidget(self.title_label) + + self.threshold_label = QLabel() + self.threshold_label.setFont(LABEL_FONT) + self.threshold_label.setText("Current Threshold: ") + self.threshold_value_label = QLabel() + self.threshold_value_label.setFont(LABEL_FONT) + self.threshold_change_label = QLabel() + self.threshold_change_label.setFont(LABEL_FONT) + self.threshold_hbox = QHBoxLayout() + self.threshold_hbox.setContentsMargins(0, 0, 0, 0) + self.threshold_hbox.setSpacing(0) + self.threshold_hbox.addWidget(self.threshold_label) + self.threshold_hbox.addWidget(self.threshold_value_label) + self.threshold_hbox.addWidget(self.threshold_change_label) + self.threshold_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + self.layout.addLayout(self.threshold_hbox) + + self.visualizer_hbox = QHBoxLayout() + self.visualizer_hbox.setContentsMargins(0, 0, 0, 0) + self.visualizer_hbox.setSpacing(10) + + self.suggestion_visualizer = EmbeddingVisualizerWindow() + self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") + self.suggestion_visualizer_button.setFont(BUTTON_FONT) + self.suggestion_visualizer_button.setMaximumWidth(240) + self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) + + self.changes_best_matches_list = ChangedBestMatchDocumentsList() + + self.visualizer_hbox.addWidget(self.changes_best_matches_list) + self.visualizer_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + self.visualizer_hbox.addWidget(self.suggestion_visualizer_button) + + self.layout.addLayout(self.visualizer_hbox) + + def _show_suggestion_visualizer(self): + self.suggestion_visualizer.setVisible(True) + + def update_threshold_value_label(self, new_threshold_value, threshold_value_change): + if round(threshold_value_change, 4) != 0: + self.threshold_value_label.setStyleSheet("color: yellow;") + change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' + self.threshold_change_label.setText(change_text) + else: + self.threshold_value_label.setStyleSheet("") + self.threshold_change_label.setText("") + + self.threshold_value_label.setText(f"{round(new_threshold_value, 4)} ") + self.threshold_label.setVisible(True) + + def update_best_match_list(self, new_best_matches: Counter[str]): + self.changes_best_matches_list.update_list(new_best_matches) + + +class ChangedBestMatchDocumentsList(QWidget): + def __init__(self): + super(ChangedBestMatchDocumentsList, self).__init__() + + self.layout = QHBoxLayout(self) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + self._info_label = QLabel("Changed best matches:") + self._info_label.setContentsMargins(0, 0, 5, 0) + self._list_labels = [] + self.layout.addWidget(self._info_label) + + def update_list(self, best_match_updates: List[BestMatchUpdate]): + self._reset_list() + + if len(best_match_updates) == 0: + no_changes_label = QLabel("") + self.layout.addWidget(no_changes_label) + self._list_labels.append(no_changes_label) + return + + for best_match_update in random.choices(best_match_updates, k=min(5, len(best_match_updates))): + label_text = f"{best_match_update.new_best_match} ({best_match_update.count}), " + label = QLabel(label_text) + label.setToolTip(f"Previous best match was: {best_match_update.old_best_match}\n" + f"Changes to token \"{best_match_update.new_best_match}\": {best_match_update.count}") + self.layout.addWidget(label) + self._list_labels.append(label) + + def _reset_list(self): + for list_label in self._list_labels: + self.layout.removeWidget(list_label) + + self._list_labels = [] + From 1cb12cec95572e377e2a98c453a71b970e709fb9 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Wed, 31 Jul 2024 00:00:23 +0200 Subject: [PATCH 34/85] fix issue with not correctly highlighted confirmed matches --- wannadb_ui/interactive_matching.py | 6 +++--- wannadb_ui/visualizations.py | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 54eb912f..f9052bfe 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -143,10 +143,10 @@ def update_nuggets(self, feedback_request): self.nugget_list.update_item_list(feedback_nuggets, params) if len(feedback_nuggets) > 0: self.visualize_area.suggestion_visualizer.update_and_display_params(attribute=attribute, - nuggets=feedback_nuggets + all_guessed_nugget_matches, + nuggets=all_guessed_nugget_matches, currently_highlighted_nugget=None, - best_guess=feedback_nuggets[0], - other_best_guesses=feedback_nuggets) + best_guess=None, + other_best_guesses=[]) if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText( diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 291f3072..c2715757 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -142,8 +142,6 @@ def add_item_to_grid(self, self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) self._nugget_to_similar_nugget[item_to_display] = None - return scatter, annotation - def highlight_best_guess(self, best_guess: InformationNugget): self._best_guess = best_guess From ee5973746020085aecac567eef31655a9ed1e541 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Wed, 31 Jul 2024 01:05:56 +0200 Subject: [PATCH 35/85] improve tooltips related to newly added nuggets shown to the user --- wannadb/matching/matching.py | 35 +++++++++++++++++++---- wannadb_ui/common.py | 45 ++++++++++++++++++++++++++++++ wannadb_ui/interactive_matching.py | 44 ++++++++++++++++------------- 3 files changed, 98 insertions(+), 26 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index e8ec6832..61db9007 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -15,7 +15,7 @@ from wannadb.matching.distance import BaseDistance from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback -from wannadb_ui.common import BestMatchUpdate +from wannadb_ui.common import BestMatchUpdate, AddedReason, NewlyAddedNuggetContext logger: logging.Logger = logging.getLogger(__name__) @@ -180,11 +180,13 @@ def _sort_remaining_documents(): # iterative user interactions logger.info("Execute interactive matching.") tik: float = time.time() + self._old_feedback_nuggets: List[InformationNugget] = [] + self._new_nugget_contexts: List[NewlyAddedNuggetContext] = [] num_feedback: int = 0 continue_matching: bool = True - old_feedback_nuggets: List[InformationNugget] = [] new_best_matches: Counter[str] = Counter[str]() new_to_old_match: Dict[str, str] = {} + old_distances: Dict[InformationNugget, float] = {} while continue_matching and num_feedback < self._max_num_feedback and remaining_documents != []: # sort remaining documents by distance _sort_remaining_documents() @@ -238,8 +240,10 @@ def _sort_remaining_documents(): # Add additional documents (most uncertain)... if self.num_bad_docs > 0 and num_nuggets_above > 0: k = min(self.num_bad_docs, num_nuggets_above) - selected_documents.extend(random.choices(remaining_documents[:num_nuggets_above], k=k)) + new_docs = random.choices(remaining_documents[:num_nuggets_above], k=k) + selected_documents.extend(new_docs) num_nuggets_above -= k + self._update_new_nugget_contexts(new_docs, AddedReason.MOST_UNCERTAIN, old_distances) # ... and those that recently got interesting additional extractions to the list if self.num_recent_docs > 0 and len(docs_with_added_nuggets) > 0: # Create a list up to double the size wanted and then sample from that instead of only taking the same most promising documents potentially over and over again @@ -247,10 +251,16 @@ def _sort_remaining_documents(): if len(selected_docs_with_added_nuggets) > self.num_recent_docs: selected_docs_with_added_nuggets = random.choices(selected_docs_with_added_nuggets, k=self.num_recent_docs) selected_documents.extend(selected_docs_with_added_nuggets) + self._update_new_nugget_contexts(selected_docs_with_added_nuggets, + AddedReason.INTERESTING_ADDITIONAL_EXTRACTION, + old_distances) selected_docs_with_added_nuggets = set(selected_docs_with_added_nuggets) # Now fill the list with documents at threshold - selected_documents.extend(doc for doc in remaining_documents[higher_left:lower_right] if doc not in selected_docs_with_added_nuggets) + docs_at_threshold_to_add = [doc for doc in remaining_documents[higher_left:lower_right] if + doc not in selected_docs_with_added_nuggets] + selected_documents.extend(docs_at_threshold_to_add) + self._update_new_nugget_contexts(docs_at_threshold_to_add, AddedReason.AT_THRESHOLD, old_distances) # Sort to unify the order across the different three sources selected_documents.sort(key=lambda x: x.nuggets[x[CurrentMatchIndexSignal]][CachedDistanceSignal], reverse=True) @@ -276,7 +286,7 @@ def _sort_remaining_documents(): "max-distance": self._max_distance, "max-distance-change": self._max_distance_change, "nuggets": feedback_nuggets, - "new-nuggets": [nugget for nugget in feedback_nuggets if nugget not in old_feedback_nuggets], + "new-nuggets": self._new_nugget_contexts, "new-best-matches": [BestMatchUpdate(new_to_old_match[new_best_match], new_best_match, new_best_matches[new_best_match]) @@ -292,7 +302,9 @@ def _sort_remaining_documents(): t1 = time.time() statistics[attribute.name]["feedback_durations"].append(t1 - t0) - old_feedback_nuggets = feedback_nuggets + self._old_feedback_nuggets = feedback_nuggets + self._new_nugget_contexts.clear() + old_distances = {nugget: nugget[CachedDistanceSignal] for nugget in document_base.nuggets} new_best_matches.clear() new_to_old_match.clear() @@ -562,6 +574,17 @@ def run_nugget_pipeline(nuggets): statistics[attribute.name]["runtime"] = tak - start_matching + def _update_new_nugget_contexts(self, new_docs: List[Document], added_reason: AddedReason, + old_distances: Dict[InformationNugget, float]): + best_matches: List[InformationNugget] = [new_doc.nuggets[new_doc[CurrentMatchIndexSignal]] for new_doc in + new_docs] + + self._new_nugget_contexts.extend([NewlyAddedNuggetContext(nugget, + old_distances[nugget] if nugget in old_distances else None, + nugget[CachedDistanceSignal], + added_reason) + for nugget in best_matches if nugget not in self._old_feedback_nuggets]) + def to_config(self) -> Dict[str, Any]: return { "identifier": self.identifier, diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index f8646c6e..b6450c7c 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -1,9 +1,13 @@ import abc +from enum import Enum +from typing import Union from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea, QFrame, QHBoxLayout, QDialog, QPushButton +from wannadb.data.data import InformationNugget + # fonts HEADER_FONT = QFont("Segoe UI", pointSize=20, weight=QFont.Weight.Bold) SUBHEADER_FONT = QFont("Segoe UI", pointSize=14, weight=QFont.Weight.DemiBold) @@ -212,6 +216,19 @@ def show_confirmation_dialog(parent, title_text, explanation_text, accept_text, return dialog.exec() +class AddedReason(Enum): + MOST_UNCERTAIN = "The documents match belongs to the considered most uncertain matches." + INTERESTING_ADDITIONAL_EXTRACTION = "The document recently got interesting additional extraction to the list." + AT_THRESHOLD = "The distance of the guessed match is within the considered range around the threshold." + + def __init__(self, corresponding_tooltip_text: str): + self._corresponding_tooltip_text = corresponding_tooltip_text + + @property + def corresponding_tooltip_text(self): + return self._corresponding_tooltip_text + + class BestMatchUpdate: def __init__(self, old_best_match, new_best_match, count): self._old_best_match = old_best_match @@ -229,3 +246,31 @@ def new_best_match(self): @property def count(self): return self._count + + +class NewlyAddedNuggetContext: + def __init__(self, nugget: InformationNugget, + old_distance: Union[float, None], + new_distance: float, + added_reason: AddedReason): + self._nugget = nugget + self._old_distance = old_distance + self._new_distance = new_distance + self._added_reason = added_reason + + @property + def nugget(self): + return self._nugget + + @property + def old_distance(self): + return self._old_distance + + @property + def new_distance(self): + return self._new_distance + + @property + def added_reason(self): + return self._added_reason + diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index f9052bfe..c450c850 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -15,7 +15,7 @@ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, LABEL_FONT_BOLD, SUBHEADER_FONT, \ - BestMatchUpdate + BestMatchUpdate, NewlyAddedNuggetContext from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ EmbeddingVisualizerWindow @@ -251,8 +251,10 @@ def update_item(self, item, params=None): f"High confidence in this match (Distance: {round(self.nugget[CachedDistanceSignal], 4)}), " f"will be included in result.") - if self.nugget in params["new-nuggets"]: - self._handle_item_is_new(params["num-feedback"] == 1) + new_nugget_contexts: List = params["new-nuggets"] + + if self.nugget in map(lambda context: context.nugget, new_nugget_contexts): + self._handle_item_is_new(self._extract_matching_context(new_nugget_contexts)) else: self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") self.setStyleSheet(self._default_style_sheet) @@ -309,22 +311,11 @@ def disable_input(self): self.match_button.setDisabled(True) self.fix_button.setDisabled(True) - def _handle_item_is_new(self, initial_selection): - old_threshold = -1 - new_threshold = -1 - old_distance = -1 - new_distance = -1 - - if initial_selection: - tooltip_text = (f'Item was added to the list as it belongs to the initial selection of items for the list. ' - 'The initial distance of its best match is within the considered range around the threshold.') - - else: - tooltip_text = ( - f'Item was newly added to the list as the distance of its best match is now within the considered ' - f'range around the current threshold.\n' - f'Old Threshold: {old_threshold} -> New Threshold: {new_threshold}\n' - f'Old Distance: {old_distance} -> New Distance: {new_distance}') + def _handle_item_is_new(self, newly_added_nugget_context): + tooltip_text = ( + f'{newly_added_nugget_context.added_reason.corresponding_tooltip_text}\n' + f'Old Distance: {round(newly_added_nugget_context.old_distance, 4) if newly_added_nugget_context.old_distance is not None else ""} -> ' + f'New Distance: {round(newly_added_nugget_context.new_distance, 4)}') self.setToolTip(tooltip_text) @@ -332,6 +323,13 @@ def _handle_item_is_new(self, initial_selection): self.setStyleSheet(f"QFrame {{ background-color: {'#e7ffe6'}; }}\n" f"QToolTip {{ background-color: {WHITE}; }}") + def _extract_matching_context(self, contexts: List[NewlyAddedNuggetContext]): + for context in contexts: + if context.nugget == self.nugget: + return context + + raise ValueError(f"Own nugget ({self.nugget}) not in given list: {contexts}") + class DocumentWidget(QWidget): def __init__(self, interactive_matching_widget): @@ -796,13 +794,19 @@ def update_list(self, best_match_updates: List[BestMatchUpdate]): return for best_match_update in random.choices(best_match_updates, k=min(5, len(best_match_updates))): - label_text = f"{best_match_update.new_best_match} ({best_match_update.count}), " + label_text = f"{best_match_update.new_best_match} ({best_match_update.count})" label = QLabel(label_text) + label.setContentsMargins(0, 0, 5, 0) label.setToolTip(f"Previous best match was: {best_match_update.old_best_match}\n" f"Changes to token \"{best_match_update.new_best_match}\": {best_match_update.count}") self.layout.addWidget(label) self._list_labels.append(label) + if len(best_match_updates) > 5: + last_label = QLabel(f"... and {len(best_match_updates) - 5} more.") + self.layout.addWidget(last_label) + self._list_labels.append(last_label) + def _reset_list(self): for list_label in self._list_labels: self.layout.removeWidget(list_label) From 8ceea6ca2e7e6730002246a1d5447f5a2b95ef2b Mon Sep 17 00:00:00 2001 From: eneapane Date: Sun, 4 Aug 2024 16:58:04 +0200 Subject: [PATCH 36/85] Track the user usage of the visual gadgets, preparation for the study --- logs/user_report.txt | 6 +++ wannadb/resources.py | 3 ++ wannadb_ui/study.py | 82 ++++++++++++++++++++++++++++++++++++ wannadb_ui/visualizations.py | 11 ++++- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 logs/user_report.txt create mode 100644 wannadb_ui/study.py diff --git a/logs/user_report.txt b/logs/user_report.txt new file mode 100644 index 00000000..c0bc4b1e --- /dev/null +++ b/logs/user_report.txt @@ -0,0 +1,6 @@ +fullscreen_button has already been clicked 1 times. +show_other_best_guesses has already been clicked 1 times. +Time spent in : 1.53 seconds. +show_other_best_guesses has already been clicked 2 times. +fullscreen_button has already been clicked 2 times. +Time spent in : 5.33 seconds. diff --git a/wannadb/resources.py b/wannadb/resources.py index c176621a..76779d64 100644 --- a/wannadb/resources.py +++ b/wannadb/resources.py @@ -16,6 +16,8 @@ from stanza import Pipeline from transformers import BertModel, BertTokenizer, BertTokenizerFast +from wannadb_ui.study import Tracker + logger: logging.Logger = logging.getLogger(__name__) RESOURCES: Dict[str, Type["BaseResource"]] = {} @@ -104,6 +106,7 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None: for resource_identifier in list(self._resources.keys()): self.unload(resource_identifier) + Tracker().dump_report() tack: float = time.time() logger.info(f"Unloaded all resources in {tack - tick} seconds.") logger.info("Exited the resource manager.") diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py new file mode 100644 index 00000000..6fc89bf5 --- /dev/null +++ b/wannadb_ui/study.py @@ -0,0 +1,82 @@ +import logging +import os +import time +from functools import wraps + +from PyQt6.QtCore import QObject, QTimer, QDateTime, pyqtSignal +from typing import Dict, Callable + +logger: logging.Logger = logging.getLogger(__name__) + + +class Tracker(QObject): + _instance = None # Class-level attribute to store the singleton instance + time_spent_signal = pyqtSignal(str, float) # Define the signal with window name and time spent + + def __new__(cls, *args, **kwargs): + if not cls._instance: + cls._instance = super(Tracker, cls).__new__(cls, *args, **kwargs) + cls._instance._initialized = False + + return cls._instance + + def __init__(self): + if not self._initialized: + super().__init__() # Call the QObject initializer + self.window_open_time = None + self.timer = QTimer() + self.timer.timeout.connect(self.calculate_time_spent) + self.button_click_counts: Dict[str, int] = {} + self._initialized = True + self.log = '' + + def dump_report(self): + tick: float = time.time() + logger.info(f"Writing the reports in the log file") + log_directory = './logs' + log_file = os.path.join(log_directory, 'user_report.txt') + os.makedirs(log_directory, exist_ok=True) + with open(log_file, 'w') as file: + file.write(self.log) + tack: float = time.time() + logger.info(f"Writing the report in {round(tick - tack, 2)} seconds") + + def start_timer(self, window_name: str): + self.window_open_time = QDateTime.currentDateTime() + self.timer.start(1000) + + def stop_timer(self, window_name: str): + self.timer.stop() + self.calculate_time_spent(window_name) + + def calculate_time_spent(self, window_name: str): + if self.window_open_time: + current_time = QDateTime.currentDateTime() + time_spent = self.window_open_time.msecsTo(current_time) / 1000.0 # Convert to seconds + self.time_spent_signal.emit(window_name, time_spent) + self.window_open_time = None + self.log += f'Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' + + def track_button_click(self, button_name: str): + if button_name in self.button_click_counts: + self.button_click_counts[button_name] += 1 + else: + self.button_click_counts[button_name] = 1 + self.log += f'{button_name} has already been clicked {self.button_click_counts[button_name]} times.\n' + + def get_button_click_count(self, button_name: str) -> int: + return self.button_click_counts.get(button_name, 0) + + +def track_button_click(button_name: str): + def decorator(func: Callable): + @wraps(func) + def wrapper(self, *args, **kwargs): + print(f"Arguments passed to {func.__name__}: args={args}, kwargs={kwargs}") + args = tuple() # empty args, because .connect() implicit arguments are added, which result in an erroneous call of the decorated method + Tracker().track_button_click(button_name) + return func(self, *args, **kwargs) + + return wrapper + + return decorator diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index c2715757..a1e70046 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -21,9 +21,9 @@ from wannadb.data.data import InformationNugget, Attribute from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ CachedDistanceSignal, CurrentThresholdSignal +from wannadb_ui.study import Tracker, track_button_click logger: logging.Logger = logging.getLogger(__name__) - RED = pg.mkColor('red') BLUE = pg.mkColor('blue') GREEN = pg.mkColor('green') @@ -318,11 +318,18 @@ def __init__(self, else: self.setVisible(False) + def showEvent(self, event): + super().showEvent(event) + Tracker().start_timer(str(self.__class__)) + def closeEvent(self, event): + Tracker().stop_timer(str(self.__class__)) event.accept() class EmbeddingVisualizerWidget(EmbeddingVisualizer, QWidget): + tracker: Tracker = Tracker() + def __init__(self): EmbeddingVisualizer.__init__(self) QWidget.__init__(self) @@ -356,6 +363,7 @@ def __init__(self): self._fullscreen_window = None self._other_best_guesses = None + @track_button_click("fullscreen_button") def _show_embedding_visualizer_window(self): if self._fullscreen_window is None: self._fullscreen_window = EmbeddingVisualizerWindow(attribute=self._attribute, @@ -392,6 +400,7 @@ def reset(self): self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) + @track_button_click(button_name="show_other_best_guesses") def _handle_show_other_best_guesses_clicked(self): if self._other_best_guesses is None: logger.warning("Can not display best guesses from other documents as these best guesses have not been " From d0b657ce7b3cb5549fd6cfafda3e51a2731afc38 Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 7 Aug 2024 10:32:51 +0200 Subject: [PATCH 37/85] Improve logging, and add logs to .gitignore --- .gitignore | 1 + logs/user_report.txt | 6 ------ wannadb_ui/study.py | 16 +++++++++++++++- wannadb_ui/visualizations.py | 14 ++++++++++++-- 4 files changed, 28 insertions(+), 9 deletions(-) delete mode 100644 logs/user_report.txt diff --git a/.gitignore b/.gitignore index df7cd50d..ae7fc66a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ /evaluation/datasets/aviation/documents /evaluation/datasets/nobel/documents /evaluation/results/ +/logs/ diff --git a/logs/user_report.txt b/logs/user_report.txt deleted file mode 100644 index c0bc4b1e..00000000 --- a/logs/user_report.txt +++ /dev/null @@ -1,6 +0,0 @@ -fullscreen_button has already been clicked 1 times. -show_other_best_guesses has already been clicked 1 times. -Time spent in : 1.53 seconds. -show_other_best_guesses has already been clicked 2 times. -fullscreen_button has already been clicked 2 times. -Time spent in : 5.33 seconds. diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index 6fc89bf5..989252fa 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -27,6 +27,7 @@ def __init__(self): self.timer = QTimer() self.timer.timeout.connect(self.calculate_time_spent) self.button_click_counts: Dict[str, int] = {} + self.total_window_open_time = {} self._initialized = True self.log = '' @@ -38,15 +39,24 @@ def dump_report(self): os.makedirs(log_directory, exist_ok=True) with open(log_file, 'w') as file: file.write(self.log) + file.write("\nTotal Statistics:\n") + file.write(f"\nButton information:\n") + for button_name, number_of_clicks in self.button_click_counts.items(): + file.write(f"\t'{button_name}' button has been clicked {number_of_clicks} times\n") + file.write(f"Window Information:\n") + for window_name, time_open_in_sec in self.total_window_open_time.items(): + file.write(f"\t{window_name} was open for a total of {time_open_in_sec} seconds\n") tack: float = time.time() logger.info(f"Writing the report in {round(tick - tack, 2)} seconds") def start_timer(self, window_name: str): self.window_open_time = QDateTime.currentDateTime() self.timer.start(1000) + self.log += f"{window_name} was opened" def stop_timer(self, window_name: str): self.timer.stop() + logger.debug(f"window_name = {window_name}") self.calculate_time_spent(window_name) def calculate_time_spent(self, window_name: str): @@ -55,6 +65,10 @@ def calculate_time_spent(self, window_name: str): time_spent = self.window_open_time.msecsTo(current_time) / 1000.0 # Convert to seconds self.time_spent_signal.emit(window_name, time_spent) self.window_open_time = None + if window_name in self.total_window_open_time: + self.total_window_open_time[window_name] += time_spent + else: + self.total_window_open_time[window_name] = time_spent self.log += f'Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' def track_button_click(self, button_name: str): @@ -62,7 +76,7 @@ def track_button_click(self, button_name: str): self.button_click_counts[button_name] += 1 else: self.button_click_counts[button_name] = 1 - self.log += f'{button_name} has already been clicked {self.button_click_counts[button_name]} times.\n' + self.log += f'{button_name} was clicked.\n' def get_button_click_count(self, button_name: str) -> int: return self.button_click_counts.get(button_name, 0) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index a1e70046..17e0f2cb 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -363,7 +363,7 @@ def __init__(self): self._fullscreen_window = None self._other_best_guesses = None - @track_button_click("fullscreen_button") + @track_button_click("fullscreen embedding visualizer") def _show_embedding_visualizer_window(self): if self._fullscreen_window is None: self._fullscreen_window = EmbeddingVisualizerWindow(attribute=self._attribute, @@ -400,7 +400,7 @@ def reset(self): self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) - @track_button_click(button_name="show_other_best_guesses") + @track_button_click(button_name="show other best guesses from other documents") def _handle_show_other_best_guesses_clicked(self): if self._other_best_guesses is None: logger.warning("Can not display best guesses from other documents as these best guesses have not been " @@ -414,6 +414,7 @@ def _handle_show_other_best_guesses_clicked(self): if self._fullscreen_window is not None: self._fullscreen_window.display_other_best_guesses(self._other_best_guesses) + @track_button_click(button_name="stop showing other best guesses from other documents") def _handle_remove_other_best_guesses_clicked(self): self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) @@ -438,6 +439,7 @@ def __init__(self, parent=None): def append_data(self, data_tuple): self.data.append(data_tuple) + @track_button_click("show bar chart") def show_bar_chart(self): if not self.data: return @@ -536,6 +538,14 @@ def clear_data(self): self.data = [] self.bar = None + def showEvent(self, event): + super().showEvent(event) + Tracker().start_timer(str(self.__class__)) + + def closeEvent(self, event): + Tracker().stop_timer(str(self.__class__)) + event.accept() + class ScatterPlotVisualizerWidget(QWidget): def __init__(self, parent=None): From 1c30e9881649099c6f08bb80f8b3d2fff2a45fb7 Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 7 Aug 2024 17:00:16 +0200 Subject: [PATCH 38/85] Small fix --- wannadb_ui/study.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index 989252fa..e9b91728 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -49,6 +49,8 @@ def dump_report(self): tack: float = time.time() logger.info(f"Writing the report in {round(tick - tack, 2)} seconds") + #todo 1 timer works for many windows (not just for one) + #todo 2 timer works for many windows simultaneously def start_timer(self, window_name: str): self.window_open_time = QDateTime.currentDateTime() self.timer.start(1000) @@ -69,7 +71,7 @@ def calculate_time_spent(self, window_name: str): self.total_window_open_time[window_name] += time_spent else: self.total_window_open_time[window_name] = time_spent - self.log += f'Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' + self.log += f'{window_name} was closed. Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' def track_button_click(self, button_name: str): if button_name in self.button_click_counts: From 2340167fc51a5dabec369fe35bebc0bf326c9ef1 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 5 Aug 2024 12:57:59 +0200 Subject: [PATCH 39/85] fix data not being set initially for scatterPlot and barChart --- wannadb_ui/interactive_matching.py | 29 +++++------------------------ wannadb_ui/visualizations.py | 25 +++++++++++++++++++------ 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index c450c850..803c2d75 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -411,12 +411,6 @@ def __init__(self, interactive_matching_widget): self.match_button.clicked.connect(self._match_button_clicked) self.buttons_widget_layout.addWidget(self.match_button) - def update_barchart(self, data): - self.cosine_barchart.append_data(data) - - def update_scatter_plot(self, data): - self.scatter_plot_widget.append_data(data) - def _match_button_clicked(self): if self.current_nugget is None: logger.info("Confirm custom nugget!") @@ -576,6 +570,9 @@ def update_document(self, nugget, other_best_guesses): currently_highlighted_nugget=nugget, best_guess=self.nuggets_sorted_by_distance[0], other_best_guesses=other_best_guesses) + self.cosine_barchart.update_data(self.nuggets_sorted_by_distance) + self.scatter_plot_widget.update_data(self.nuggets_sorted_by_distance) + else: self.idx_mapper = {} for idx in range(len(self.document.text)): @@ -590,14 +587,6 @@ def update_document(self, nugget, other_best_guesses): scroll_cursor.setPosition(nugget.start_char) self.text_edit.setTextCursor(scroll_cursor) self.text_edit.ensureCursorVisible() - # Clear bar chart data when updating document - self.clear_barchart_data() - - # Clear scatter plot data when updating document - self.clear_scatter_plot_data() - - def clear_barchart_data(self): - self.cosine_barchart.clear_data() def clear_scatter_plot_data(self): self.scatter_plot_widget.clear_data() @@ -650,7 +639,8 @@ def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: def update_item(self, item, params=None): self.nugget = item - sanitized_text, distance = self.get_nugget_data() + sanitized_text = self.nugget.text.replace("\n", " ") + distance = np.round(self.nugget[CachedDistanceSignal], 3) self.text_label.setText(sanitized_text) self.distance_label.setText(str(distance)) @@ -661,9 +651,6 @@ def update_item(self, item, params=None): ) else: self.setStyleSheet(f"background-color: {LIGHT_YELLOW}") - self.suggestion_list_widget.interactive_matching_widget.document_widget.update_barchart(self.get_nugget_data()) - self.suggestion_list_widget.interactive_matching_widget.document_widget.update_scatter_plot( - self.get_nugget_data()) def enable_input(self): pass @@ -671,12 +658,6 @@ def enable_input(self): def disable_input(self): pass - def get_nugget_data(self): - sanitized_text = self.nugget.text - sanitized_text = sanitized_text.replace("\n", " ") - distance = np.round(self.nugget[CachedDistanceSignal], 3) - return sanitized_text, distance - class CustomSelectionItemWidget(QWidget): diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 17e0f2cb..df5e0356 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -60,6 +60,10 @@ def build_nuggets_annotation_text(nugget) -> str: return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" +def create_sanitized_text(nugget): + return nugget.text.replace("\n", " ") + + class EmbeddingVisualizer: def __init__(self, attribute: Attribute = None, @@ -435,9 +439,14 @@ def __init__(self, parent=None): self.button.clicked.connect(self.show_bar_chart) self.window = None self.current_annotation_index = None + self.bar = None + + def update_data(self, nuggets): + self.reset() - def append_data(self, data_tuple): - self.data.append(data_tuple) + self.data = [(create_sanitized_text(nugget), + np.round(nugget[CachedDistanceSignal], 3)) + for nugget in nuggets] @track_button_click("show bar chart") def show_bar_chart(self): @@ -534,7 +543,7 @@ def on_pick(self, event): self.current_annotation_index = index self.bar_chart_canvas.draw_idle() - def clear_data(self): + def reset(self): self.data = [] self.bar = None @@ -565,10 +574,14 @@ def __init__(self, parent=None): self.y = None self.scatter = None - def append_data(self, data_tuple): - self.data.append(data_tuple) + def update_data(self, nuggets): + self.reset() + + self.data = [(create_sanitized_text(nugget), + np.round(nugget[CachedDistanceSignal], 3)) + for nugget in nuggets] - def clear_data(self): + def reset(self): self.data = [] self.texts = None self.distances = None From 2cc0a3727e102f5c84081c30545f028e300fe7c8 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 5 Aug 2024 20:14:42 +0200 Subject: [PATCH 40/85] add possibility to en-/disable visualizations --- wannadb_ui/common.py | 23 +++++- wannadb_ui/interactive_matching.py | 112 +++++++++++++++++++++++------ wannadb_ui/main_window.py | 55 +++++++++++++- wannadb_ui/visualizations.py | 5 ++ 4 files changed, 172 insertions(+), 23 deletions(-) diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index b6450c7c..0945c5a1 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -90,7 +90,17 @@ def __init__(self, main_window_content, sub_header_text): self.layout.addWidget(self.sub_header) -class CustomScrollableList(QWidget): +class VisualizationsProvidingItem: + @abc.abstractmethod + def show_visualizations(self): + raise NotImplementedError + + @abc.abstractmethod + def hide_visualizations(self): + raise NotImplementedError + + +class CustomScrollableList(QWidget, VisualizationsProvidingItem): def __init__(self, parent, item_type, floating_widget=None, orientation="vertical", above_widget=None): super(CustomScrollableList, self).__init__() @@ -169,6 +179,16 @@ def disable_input(self): for item_widget in self.item_widgets: item_widget.disable_input() + def show_visualizations(self): + for item_widget in self.item_widgets: + if isinstance(item_widget, VisualizationsProvidingItem): + item_widget.show_visualizations() + + def hide_visualizations(self): + for item_widget in self.item_widgets: + if isinstance(item_widget, VisualizationsProvidingItem): + item_widget.hide_visualizations() + class CustomScrollableListItem(QFrame): @@ -273,4 +293,3 @@ def new_distance(self): @property def added_reason(self): return self._added_reason - diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 803c2d75..dbac14e2 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -1,4 +1,5 @@ import logging +from abc import ABC from collections import Counter import random from typing import List @@ -15,7 +16,7 @@ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, LABEL_FONT_BOLD, SUBHEADER_FONT, \ - BestMatchUpdate, NewlyAddedNuggetContext + BestMatchUpdate, NewlyAddedNuggetContext, VisualizationsProvidingItem from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ EmbeddingVisualizerWindow @@ -77,12 +78,20 @@ def show_document_widget(self): self.layout.addWidget(self.document_widget) self.stop_button.hide() + def enable_visualizations(self): + self.document_widget.show_visualizations() + self.nugget_list_widget.show_visualizations() + + def disable_visualizations(self): + self.document_widget.hide_visualizations() + self.nugget_list_widget.hide_visualizations() + def _stop_button_clicked(self): self.show_nugget_list_widget() self.main_window.give_feedback_task({"message": "stop-interactive-matching"}) -class NuggetListWidget(QWidget): +class NuggetListWidget(QWidget, VisualizationsProvidingItem): def __init__(self, interactive_matching_widget): super(NuggetListWidget, self).__init__(interactive_matching_widget) self.interactive_matching_widget = interactive_matching_widget @@ -99,6 +108,7 @@ def __init__(self, interactive_matching_widget): self.visualize_area = VisualizationArea() self.layout.addWidget(self.visualize_area) self.visualize_area.setVisible(False) + self.visualizations = True # nugget list self.num_nuggets_above_label = QLabel("") @@ -123,7 +133,6 @@ def update_nuggets(self, feedback_request): current_threshold = feedback_request["max-distance"] threshold_change = feedback_request["max-distance-change"] - self.description.setText( "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") @@ -138,7 +147,7 @@ def update_nuggets(self, feedback_request): "num-feedback": feedback_request["num-feedback"] } - self.visualize_area.setVisible(True) + self.visualize_area.setVisible(self.visualizations) self.nugget_list.update_item_list(feedback_nuggets, params) if len(feedback_nuggets) > 0: @@ -165,18 +174,32 @@ def enable_input(self): def disable_input(self): self.nugget_list.disable_input() + def show_visualizations(self): + self.visualizations = True + + self.visualize_area.show() + self.nugget_list.show_visualizations() + + def hide_visualizations(self): + self.visualizations = False + + self.visualize_area.hide() + self.nugget_list.hide_visualizations() -class NuggetListItemWidget(CustomScrollableListItem): + +class NuggetListItemWidget(CustomScrollableListItem, VisualizationsProvidingItem): def __init__(self, nugget_list_widget): super(NuggetListItemWidget, self).__init__(nugget_list_widget) self.nugget_list_widget = nugget_list_widget self.nugget = None self.other_best_guesses = None - self._default_style_sheet = "QWidget#nuggetListItemWidget { background-color: white}" + self._default_stylesheet = "QWidget#nuggetListItemWidget { background-color: white}" + self._tooltip_text = "" + self._visualizations = True self.setFixedHeight(45) self.setObjectName("nuggetListItemWidget") - self.setStyleSheet(self._default_style_sheet) + self.setStyleSheet(self._default_stylesheet) self.layout = QHBoxLayout(self) self.layout.setContentsMargins(20, 0, 20, 0) @@ -241,14 +264,15 @@ def update_item(self, item, params=None): if max_distance < self.nugget[CachedDistanceSignal]: color = LIGHT_YELLOW self.confidence_button.setIcon(ICON_LOW_CONFIDENCE) + self.confidence_button.setToolTip( - f"Low confidence in this match (Distance: {round(self.nugget[CachedDistanceSignal], 4)}), " + f"Low confidence in this match {self._build_distance_text() if self._visualizations else ''}, " f"will not be included in result.") else: color = YELLOW self.confidence_button.setIcon(ICON_HIGH_CONFIDENCE) self.confidence_button.setToolTip( - f"High confidence in this match (Distance: {round(self.nugget[CachedDistanceSignal], 4)}), " + f"High confidence in this match {self._build_distance_text() if self._visualizations else ''}, " f"will be included in result.") new_nugget_contexts: List = params["new-nuggets"] @@ -256,9 +280,9 @@ def update_item(self, item, params=None): if self.nugget in map(lambda context: context.nugget, new_nugget_contexts): self._handle_item_is_new(self._extract_matching_context(new_nugget_contexts)) else: - self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") - self.setStyleSheet(self._default_style_sheet) - self.setToolTip("") + self._update_stylesheets(False) + self._tooltip_text = "" + self.setToolTip(self._tooltip_text) self.text_edit.setText("") formatted_text = ( @@ -311,17 +335,34 @@ def disable_input(self): self.match_button.setDisabled(True) self.fix_button.setDisabled(True) + def show_visualizations(self): + self._visualizations = True + + if self._tooltip_text != "": + self.setToolTip(self._tooltip_text) + self.confidence_button.setToolTip( + f"Low confidence in this match {self._build_distance_text()}, will not be included in result.") + self._update_stylesheets(item_is_new=self._tooltip_text != "") + + def hide_visualizations(self): + self._visualizations = False + + self.setToolTip("") + self.confidence_button.setToolTip( + f"Low confidence in this match, will not be included in result.") + self._update_stylesheets(False) + def _handle_item_is_new(self, newly_added_nugget_context): - tooltip_text = ( + self._tooltip_text = ( f'{newly_added_nugget_context.added_reason.corresponding_tooltip_text}\n' f'Old Distance: {round(newly_added_nugget_context.old_distance, 4) if newly_added_nugget_context.old_distance is not None else ""} -> ' f'New Distance: {round(newly_added_nugget_context.new_distance, 4)}') - self.setToolTip(tooltip_text) + if not self._visualizations: + return - self.text_edit.setStyleSheet(f"color: black; background-color: {'#e7ffe6'}") - self.setStyleSheet(f"QFrame {{ background-color: {'#e7ffe6'}; }}\n" - f"QToolTip {{ background-color: {WHITE}; }}") + self.setToolTip(self._tooltip_text) + self._update_stylesheets(True) def _extract_matching_context(self, contexts: List[NewlyAddedNuggetContext]): for context in contexts: @@ -330,8 +371,20 @@ def _extract_matching_context(self, contexts: List[NewlyAddedNuggetContext]): raise ValueError(f"Own nugget ({self.nugget}) not in given list: {contexts}") + def _build_distance_text(self): + return f"(Distance: {round(self.nugget[CachedDistanceSignal], 4)})" if self._visualizations else "" + + def _update_stylesheets(self, item_is_new): + if item_is_new: + self.setStyleSheet((f"QFrame {{ background-color: {'#e7ffe6'}; }}\n" + f"QToolTip {{ background-color: {WHITE}; }}")) + self.text_edit.setStyleSheet(f"color: black; background-color: {'#e7ffe6'}") + else: + self.setStyleSheet(self._default_stylesheet) + self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") + -class DocumentWidget(QWidget): +class DocumentWidget(QWidget, VisualizationsProvidingItem): def __init__(self, interactive_matching_widget): super(DocumentWidget, self).__init__(interactive_matching_widget) self.interactive_matching_widget = interactive_matching_widget @@ -604,6 +657,16 @@ def disable_input(self): def update_attribute(self, attribute): self.current_attribute = attribute + def show_visualizations(self): + self.upper_buttons_widget.show() + self.visualizer.show() + self.suggestion_list.show_visualizations() + + def hide_visualizations(self): + self.upper_buttons_widget.hide() + self.visualizer.hide() + self.suggestion_list.hide_visualizations() + def _highlight_best_guess(self, best_guess): if best_guess is None: return @@ -611,7 +674,7 @@ def _highlight_best_guess(self, best_guess): self.visualizer.highlight_best_guess(best_guess) -class SuggestionListItemWidget(CustomScrollableListItem): +class SuggestionListItemWidget(CustomScrollableListItem, VisualizationsProvidingItem): def __init__(self, suggestion_list_widget): super(SuggestionListItemWidget, self).__init__(suggestion_list_widget) @@ -652,6 +715,12 @@ def update_item(self, item, params=None): else: self.setStyleSheet(f"background-color: {LIGHT_YELLOW}") + def show_visualizations(self): + self.distance_label.show() + + def hide_visualizations(self): + self.distance_label.hide() + def enable_input(self): pass @@ -751,6 +820,10 @@ def update_threshold_value_label(self, new_threshold_value, threshold_value_chan def update_best_match_list(self, new_best_matches: Counter[str]): self.changes_best_matches_list.update_list(new_best_matches) + def hide(self): + super().hide() + self.suggestion_visualizer.hide() + class ChangedBestMatchDocumentsList(QWidget): def __init__(self): @@ -793,4 +866,3 @@ def _reset_list(self): self.layout.removeWidget(list_label) self._list_labels = [] - diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index eb8150e7..eb51bd2c 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -237,6 +237,24 @@ def save_statistics_to_json_task(self): # noinspection PyUnresolvedReferences self.save_statistics_to_json.emit(path, self.statistics) + def enable_visualizations_task(self): + logger.info("Execute task 'enable_visualizations_task'.") + + self.visualizations = True + + self.interactive_matching_widget.enable_visualizations() + self.enable_visualizations_action.setEnabled(False) + self.disable_visualizations_action.setEnabled(True) + + def disable_visualizations_task(self): + logger.info("Execute task 'disable_visualizations_task'.") + + self.visualizations = False + + self.interactive_matching_widget.disable_visualizations() + self.enable_visualizations_action.setEnabled(True) + self.disable_visualizations_action.setEnabled(False) + def show_document_base_creator_widget_task(self): logger.info("Execute task 'show_document_base_creator_widget_task'.") @@ -281,6 +299,8 @@ def give_feedback_task(self, feedback): self.api.feedback = feedback self.feedback_cond.wakeAll() + self._enable_visualization_settings() + def interactive_table_population_task(self): logger.info("Execute task 'interactive_table_population_task'.") @@ -335,6 +355,8 @@ def to_start_state(self): else: self.enable_collect_statistics_action.setEnabled(True) + self._enable_visualization_settings() + self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.document_base_viewer_widget.hide() self.document_base_creator_widget.hide() @@ -366,6 +388,8 @@ def to_create_document_base_state(self): else: self.enable_collect_statistics_action.setEnabled(True) + self._enable_visualization_settings() + self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() self.document_base_creation_widget.hide() @@ -386,6 +410,8 @@ def to_creating_document_base_state(self): self.disable_global_input() + self._enable_visualization_settings() + self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() self.document_base_creator_widget.hide() @@ -429,6 +455,8 @@ def to_view_document_base_state(self): else: self.enable_collect_statistics_action.setEnabled(True) + self._enable_visualization_settings() + self.document_base_viewer_widget.enable_input() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -458,6 +486,8 @@ def to_interactive_matching_state(self): else: self.enable_collect_statistics_action.setEnabled(True) + self._enable_visualization_settings() + self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() self.document_base_viewer_widget.hide() @@ -472,6 +502,10 @@ def to_interactive_matching_state(self): self.interactive_matching_widget.show() self.central_widget_layout.update() + def _enable_visualization_settings(self): + self.enable_visualizations_action.setEnabled(not self.visualizations) + self.disable_visualizations_action.setEnabled(self.visualizations) + # noinspection PyUnresolvedReferences def __init__(self) -> None: super(MainWindow, self).__init__() @@ -482,6 +516,7 @@ def __init__(self) -> None: self.document_base = None self.statistics = None self.collect_statistics = True + self.visualizations = True self.attributes_to_match = None self.cache_db = None @@ -610,6 +645,16 @@ def __init__(self) -> None: self.save_statistics_to_json_action.triggered.connect(self.save_statistics_to_json_task) self._all_actions.append(self.save_statistics_to_json_action) + self.enable_visualizations_action = QAction("&Enable visualizations", self) + self.enable_visualizations_action.setStatusTip("Enable visualization widgets.") + self.enable_visualizations_action.triggered.connect(self.enable_visualizations_task) + self._all_actions.append(self.enable_visualizations_action) + + self.disable_visualizations_action = QAction("&Disable visualizations", self) + self.disable_visualizations_action.setStatusTip("Disable visualization widgets.") + self.disable_visualizations_action.triggered.connect(self.disable_visualizations_task) + self._all_actions.append(self.disable_visualizations_action) + # set up the menu bar self.menubar = self.menuBar() self.menubar.setFont(MENU_FONT) @@ -635,13 +680,21 @@ def __init__(self) -> None: self.population_menu.addAction(self.forget_matches_for_attribute_action) self.population_menu.addAction(self.forget_matches_action) - self.statistics_menu = self.menubar.addMenu("&Statistics") + self.settings_menu = self.menubar.addMenu("&Settings") + self.settings_menu.setFont(MENU_FONT) + + self.statistics_menu = self.settings_menu.addMenu("&Statistics") self.statistics_menu.setFont(MENU_FONT) self.statistics_menu.addAction(self.enable_collect_statistics_action) self.statistics_menu.addAction(self.disable_collect_statistics_action) self.statistics_menu.addSeparator() self.statistics_menu.addAction(self.save_statistics_to_json_action) + self.visualizations_menu = self.settings_menu.addMenu("&Visualizations") + self.visualizations_menu.setFont(MENU_FONT) + self.visualizations_menu.addAction(self.enable_visualizations_action) + self.visualizations_menu.addAction(self.disable_visualizations_action) + # main UI self.central_widget = QWidget(self) self.central_widget_layout = QHBoxLayout(self.central_widget) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index df5e0356..1ba41458 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -404,6 +404,11 @@ def reset(self): self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) + def hide(self): + super().hide() + if self._fullscreen_window is not None: + self._fullscreen_window.close() + @track_button_click(button_name="show other best guesses from other documents") def _handle_show_other_best_guesses_clicked(self): if self._other_best_guesses is None: From 4dcba8bf1b2076672e7e1031f162b4db6b34e0bf Mon Sep 17 00:00:00 2001 From: nils-bz Date: Wed, 7 Aug 2024 04:10:50 +0200 Subject: [PATCH 41/85] add lists indicating which nuggets moved below/above threshold due to last feedback --- wannadb/matching/matching.py | 84 +++++++++-- wannadb_ui/common.py | 86 +++++++++-- wannadb_ui/data_insights.py | 223 +++++++++++++++++++++++++++++ wannadb_ui/interactive_matching.py | 136 ++---------------- 4 files changed, 385 insertions(+), 144 deletions(-) create mode 100644 wannadb_ui/data_insights.py diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 61db9007..46f0376c 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -2,7 +2,7 @@ import logging import random import time -from typing import Any, Dict, List, Callable, Tuple, Counter +from typing import Any, Dict, List, Tuple, Counter, Optional import numpy as np @@ -15,7 +15,8 @@ from wannadb.matching.distance import BaseDistance from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback -from wannadb_ui.common import BestMatchUpdate, AddedReason, NewlyAddedNuggetContext +from wannadb_ui.common import BestMatchUpdate, AddedReason, NewlyAddedNuggetContext, NuggetUpdatesContext, \ + ThresholdPosition, ThresholdPositionUpdate logger: logging.Logger = logging.getLogger(__name__) @@ -132,7 +133,7 @@ def _call( logger.info(f"Matching attribute '{attribute.name}'.") start_matching: float = time.time() self._max_distance = self._default_max_distance - self._max_distance_change = 0 + self._old_max_distance = -1 attribute[CurrentThresholdSignal] = CurrentThresholdSignal(self._max_distance) statistics[attribute.name]["max_distances"] = [self._max_distance] statistics[attribute.name]["feedback_durations"] = [] @@ -279,18 +280,25 @@ def _sort_remaining_documents(): all_guessed_nugget_matches = tuple([doc.nuggets[doc[CurrentMatchIndexSignal]] for doc in document_base.documents]) num_feedback += 1 statistics[attribute.name]["num_feedback"] += 1 + t0 = time.time() + + best_match_updates = [BestMatchUpdate(new_to_old_match[new_best_match], + new_best_match, + new_best_matches[new_best_match]) + for new_best_match in new_best_matches.keys()] + threshold_position_updates = self._compute_threshold_position_updates(document_base, old_distances) + nugget_updates_context = NuggetUpdatesContext(newly_added_nugget_contexts=self._new_nugget_contexts, + best_match_updates=best_match_updates, + threshold_position_updates=threshold_position_updates) + feedback_result: Dict[str, Any] = interaction_callback( self.identifier, { "max-distance": self._max_distance, - "max-distance-change": self._max_distance_change, + "max-distance-change": self._max_distance - self._old_max_distance if self._old_max_distance != -1 else 0, "nuggets": feedback_nuggets, - "new-nuggets": self._new_nugget_contexts, - "new-best-matches": [BestMatchUpdate(new_to_old_match[new_best_match], - new_best_match, - new_best_matches[new_best_match]) - for new_best_match in new_best_matches.keys()], + "nugget-updates-context": nugget_updates_context, "all-guessed-nugget-matches": all_guessed_nugget_matches, "attribute": attribute, "num-feedback": num_feedback, @@ -340,7 +348,7 @@ def _sort_remaining_documents(): if feedback_nuggets_old_cached_distances[ix] < self._max_distance: min_dist = min(min_dist, feedback_nuggets[ix][CachedDistanceSignal]) if min_dist < self._max_distance: - self._max_distance_change = min_dist - self._max_distance + self._old_max_distance = self._max_distance self._max_distance = min_dist attribute[CurrentThresholdSignal] = CurrentThresholdSignal(min_dist) statistics[attribute.name]["max_distances"].append(min_dist) @@ -518,7 +526,7 @@ def run_nugget_pipeline(nuggets): if feedback_nuggets_old_cached_distances[ix] > self._max_distance: max_dist = max(max_dist, feedback_nuggets[ix][CachedDistanceSignal]) if max_dist > self._max_distance: - self._max_distance_change = max_dist - self._max_distance + self._old_max_distance = self._max_distance self._max_distance = max_dist attribute[CurrentThresholdSignal] = CurrentThresholdSignal(max_dist) statistics[attribute.name]["max_distances"].append(max_dist) @@ -585,6 +593,60 @@ def _update_new_nugget_contexts(self, new_docs: List[Document], added_reason: Ad added_reason) for nugget in best_matches if nugget not in self._old_feedback_nuggets]) + def _compute_threshold_position_updates(self, document_base, old_distances): + threshold_position_updates: Dict[str, Tuple[ThresholdPositionUpdate, Optional[ThresholdPositionUpdate]]] = dict() + + for nugget in document_base.nuggets: + is_best_guess = nugget.document.nuggets[nugget.document[CurrentMatchIndexSignal]].text == nugget.text + if not is_best_guess: + continue + + old_update = threshold_position_updates[nugget.text][0] if nugget.text in threshold_position_updates else None + + if self._old_max_distance == -1: + old_threshold_position = None + else: + old_threshold_position = ThresholdPosition.ABOVE if old_distances[nugget] > self._old_max_distance \ + else ThresholdPosition.BELOW + new_threshold_position = ThresholdPosition.ABOVE if nugget[CachedDistanceSignal] > self._max_distance \ + else ThresholdPosition.BELOW + if old_threshold_position != new_threshold_position: + if (old_update is not None and + old_update.old_position == old_threshold_position and + old_update.new_position == new_threshold_position): + threshold_position_updates[nugget.text] = (ThresholdPositionUpdate(nugget.text, + old_threshold_position, + new_threshold_position, + old_distances[nugget] if nugget in old_distances else None, + nugget[CachedDistanceSignal], + old_update.count + 1), + None) + elif old_update is not None: + threshold_position_updates[nugget.text] = (old_update, + ThresholdPositionUpdate(nugget.text, + old_threshold_position, + new_threshold_position, + old_distances[nugget] if nugget in old_distances else None, + nugget[CachedDistanceSignal], + 1)) + else: + threshold_position_updates[nugget.text] = (ThresholdPositionUpdate(nugget.text, + old_threshold_position, + new_threshold_position, + old_distances[nugget] if nugget in old_distances else None, + nugget[CachedDistanceSignal], + 1), + None) + + result = [] + for first_update, second_update in threshold_position_updates.values(): + if second_update is None: + result.append(first_update) + else: + result.extend([first_update, second_update]) + + return result + def to_config(self) -> Dict[str, Any]: return { "identifier": self.identifier, diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 0945c5a1..102e7441 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -1,6 +1,6 @@ import abc from enum import Enum -from typing import Union +from typing import Union, List, Optional from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont @@ -249,22 +249,64 @@ def corresponding_tooltip_text(self): return self._corresponding_tooltip_text +class ThresholdPosition(Enum): + ABOVE = 1 + BELOW = 2 + + class BestMatchUpdate: - def __init__(self, old_best_match, new_best_match, count): - self._old_best_match = old_best_match - self._new_best_match = new_best_match - self._count = count + def __init__(self, old_best_match: str, new_best_match: str, count: int): + self._old_best_match: str = old_best_match + self._new_best_match: str = new_best_match + self._count: int = count @property - def old_best_match(self): + def old_best_match(self) -> str: return self._old_best_match @property - def new_best_match(self): + def new_best_match(self) -> str: return self._new_best_match @property - def count(self): + def count(self) -> int: + return self._count + + +class ThresholdPositionUpdate: + def __init__(self, best_guess: str, + old_position: Optional[ThresholdPosition], new_position: ThresholdPosition, + old_distance: Optional[float], new_distance: float, + count: int): + self._best_guess: str = best_guess + self._old_position: Optional[ThresholdPosition] = old_position + self._new_position: ThresholdPosition = new_position + self._old_distance: float = old_distance + self._new_distance: float = new_distance + self._count: int = count + + @property + def best_guess(self) -> str: + return self._best_guess + + @property + def old_position(self) -> Optional[ThresholdPosition]: + return self._old_position + + @property + def new_position(self) -> ThresholdPosition: + return self._new_position + + @property + def old_distance(self) -> Optional[float]: + return self._old_distance + + @property + def new_distance(self) -> float: + return self._new_distance + + @property + def count(self) -> int: return self._count @@ -293,3 +335,31 @@ def new_distance(self): @property def added_reason(self): return self._added_reason + + +class NuggetUpdateType(Enum): + NEWLY_ADDED = 1 + THRESHOLD_POSITION_UPDATE = 2 + BEST_MATCH_UPDATE = 3 + + +class NuggetUpdatesContext: + def __init__(self, + newly_added_nugget_contexts: List[NewlyAddedNuggetContext], + best_match_updates: List[BestMatchUpdate], + threshold_position_updates: List[ThresholdPositionUpdate]): + self._newly_added_nugget_contexts: List[NewlyAddedNuggetContext] = newly_added_nugget_contexts + self._best_match_updates: List[BestMatchUpdate] = best_match_updates + self._threshold_position_updates: List[ThresholdPositionUpdate] = threshold_position_updates + + @property + def newly_added_nugget_contexts(self) -> List[NewlyAddedNuggetContext]: + return self._newly_added_nugget_contexts + + @property + def best_match_updates(self) -> List[BestMatchUpdate]: + return self._best_match_updates + + @property + def threshold_position_updates(self) -> List[ThresholdPositionUpdate]: + return self._threshold_position_updates diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py new file mode 100644 index 00000000..dd8441e3 --- /dev/null +++ b/wannadb_ui/data_insights.py @@ -0,0 +1,223 @@ +import abc +import random +from typing import Generic, TypeVar, List, Tuple + +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSpacerItem, QSizePolicy, QPushButton + +from wannadb_ui.common import BestMatchUpdate, ThresholdPositionUpdate, ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ + BUTTON_FONT +from wannadb_ui.visualizations import EmbeddingVisualizerWindow + +UPDATE_TYPE = TypeVar("UPDATE_TYPE") + + +class ChangesList(QWidget, Generic[UPDATE_TYPE]): + def __init__(self, info_label_text, tooltip_text): + super(ChangesList, self).__init__() + + self._layout: QHBoxLayout = QHBoxLayout(self) + self._layout.setSpacing(0) + self._layout.setContentsMargins(0, 0, 0, 0) + + self._add_info_label(info_label_text, tooltip_text) + + def update_list(self, updates: List[UPDATE_TYPE]): + self._reset_list() + + if len(updates) == 0: + no_changes_label = QLabel("-") + no_changes_label.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(no_changes_label) + self._list_labels.append(no_changes_label) + return + + updates_to_add = random.sample(updates, k=min(7, len(updates))) + for update in updates_to_add: + label_text, tooltip_text = self._create_label_and_tooltip_text(update) + label = QLabel(label_text) + label.setContentsMargins(0, 0, 8, 0) + label.setToolTip(tooltip_text) + self._layout.addWidget(label) + self._list_labels.append(label) + + if len(updates) > 7: + last_label = QLabel(f"... and {len(updates) - 7} more.") + last_label.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(last_label) + self._list_labels.append(last_label) + + @abc.abstractmethod + def _create_label_and_tooltip_text(self, update: UPDATE_TYPE) -> Tuple[str, str]: + pass + + def _reset_list(self): + for list_label in self._list_labels: + self._layout.removeWidget(list_label) + + self._list_labels = [] + + def _add_info_label(self, info_label_text: str, tooltip_text: str): + self._info_label: QLabel = QLabel(info_label_text) + self._info_label.setContentsMargins(0, 0, 8, 0) + self._list_labels: List[QWidget] = list() + self._layout.addWidget(self._info_label) + + self._info_label.setToolTip(tooltip_text) + + +class ChangedBestMatchDocumentsList(ChangesList[BestMatchUpdate]): + def __init__(self): + tooltip_text = ("The distance associated with each nugget is recomputed after every feedback round.\n" + "Therefore the best guess of an document (nugget with lowest distance) might change " + "after a feedback round. Such best guesses are listed here.") + super(ChangedBestMatchDocumentsList, self).__init__("Changed best guesses:", tooltip_text) + + def _create_label_and_tooltip_text(self, update: BestMatchUpdate) -> Tuple[str, str]: + label_text = f"{update.new_best_match} ({update.count})" + tooltip_text = (f"Previous best match was: {update.old_best_match}\n" + f"Changes to token \"{update.new_best_match}\": {update.count}") + + return label_text, tooltip_text + + +class ChangedThresholdPositionList(ChangesList[ThresholdPositionUpdate]): + def __init__(self, info_label_text, tooltip_text): + super(ChangedThresholdPositionList, self).__init__(info_label_text, tooltip_text) + + def update_list(self, threshold_updates: List[ThresholdPositionUpdate]): + relevant_updates = self._extract_relevant_updates(threshold_updates) + + super().update_list(relevant_updates) + + def _create_label_and_tooltip_text(self, update: ThresholdPositionUpdate) -> Tuple[str, str]: + moving_direction = update.new_position.name.lower() + + label_text = f"{update.best_guess} ({update.count})" + tooltip_text = (f"Due to your last feedback {update.best_guess} moved {moving_direction} the threshold.\n" + f"Old distance: {round(update.old_distance, 4) if update.old_distance is not None else ''} -> New distance: {round(update.new_distance, 4)}\n" + f"This happened for {update.count - 1} similar nuggets as well.") + + return label_text, tooltip_text + + @abc.abstractmethod + def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]) -> List[ThresholdPositionUpdate]: + pass + + +class ChangedThresholdPositionToAboveList(ChangedThresholdPositionList): + def __init__(self): + tooltip_text = ("The distance associated with each nugget as well as the threshold is recomputed after every " + "feedback round.\n" + "Therefore the best guess of an document might not be below the threshold anymore. Such best " + "guesses are listed here.") + super(ChangedThresholdPositionToAboveList, self).__init__("Moved above threshold:", tooltip_text) + + def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]): + return list(filter(lambda update: (update.old_position != update.new_position and + update.new_position == ThresholdPosition.ABOVE), + threshold_updates)) + + +class ChangedThresholdPositionToBelowList(ChangedThresholdPositionList): + def __init__(self): + tooltip_text = ("The distance associated with each nugget as well as the threshold is recomputed after every " + "feedback round.\n" + "Therefore the best guess of an document might not be above the threshold anymore. Such best " + "guesses are listed here.") + super(ChangedThresholdPositionToBelowList, self).__init__("Moved below threshold:", tooltip_text) + + def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]): + return list(filter(lambda update: (update.old_position != update.new_position and + update.new_position == ThresholdPosition.BELOW), + threshold_updates)) + + +class DataInsightsArea(QWidget): + def __init__(self): + super(DataInsightsArea, self).__init__() + + self.layout = QVBoxLayout(self) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + self.title_label = QLabel("Data Insights") + self.title_label.setFont(SUBHEADER_FONT) + self.title_label.setContentsMargins(0, 5, 0, 5) + self.layout.addWidget(self.title_label) + + self.threshold_label = QLabel() + self.threshold_label.setFont(LABEL_FONT) + self.threshold_label.setText("Current Threshold: ") + self.threshold_value_label = QLabel() + self.threshold_value_label.setFont(LABEL_FONT) + self.threshold_change_label = QLabel() + self.threshold_change_label.setFont(LABEL_FONT) + self.threshold_hbox = QHBoxLayout() + self.threshold_hbox.setContentsMargins(0, 0, 0, 0) + self.threshold_hbox.setSpacing(0) + self.threshold_hbox.addWidget(self.threshold_label) + self.threshold_hbox.addWidget(self.threshold_value_label) + self.threshold_hbox.addWidget(self.threshold_change_label) + self.threshold_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + self.layout.addLayout(self.threshold_hbox) + + self.changes_list1_hbox = QHBoxLayout() + self.changes_list1_hbox.setContentsMargins(0, 0, 0, 0) + self.changes_list1_hbox.setSpacing(0) + self.threshold_position_changes_below_list = ChangedThresholdPositionToBelowList() + self.changes_list1_hbox.addWidget(self.threshold_position_changes_below_list) + self.changes_list1_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + + self.changes_list2_hbox = QHBoxLayout() + self.changes_list2_hbox.setContentsMargins(0, 0, 0, 0) + self.changes_list2_hbox.setSpacing(0) + self.threshold_position_changes_above_list = ChangedThresholdPositionToAboveList() + self.changes_list2_hbox.addWidget(self.threshold_position_changes_above_list) + self.changes_list2_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + + self.changes_list3_hbox = QHBoxLayout() + self.changes_list3_hbox.setContentsMargins(0, 0, 0, 0) + self.changes_list3_hbox.setSpacing(0) + + self.suggestion_visualizer = EmbeddingVisualizerWindow() + self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") + self.suggestion_visualizer_button.setContentsMargins(0, 0, 0, 0) + self.suggestion_visualizer_button.setFont(BUTTON_FONT) + self.suggestion_visualizer_button.setMaximumWidth(240) + self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) + + self.changes_best_matches_list = ChangedBestMatchDocumentsList() + + self.changes_list3_hbox.addWidget(self.changes_best_matches_list) + self.changes_list3_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + self.changes_list3_hbox.addWidget(self.suggestion_visualizer_button) + + self.layout.addLayout(self.changes_list1_hbox) + self.layout.addLayout(self.changes_list2_hbox) + self.layout.addLayout(self.changes_list3_hbox) + + def _show_suggestion_visualizer(self): + self.suggestion_visualizer.setVisible(True) + + def update_threshold_value_label(self, new_threshold_value, threshold_value_change): + if round(threshold_value_change, 4) != 0: + self.threshold_value_label.setStyleSheet("color: yellow;") + change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' + self.threshold_change_label.setText(change_text) + else: + self.threshold_value_label.setStyleSheet("") + self.threshold_change_label.setText("") + + self.threshold_value_label.setText(f"{round(new_threshold_value, 4)} ") + self.threshold_label.setVisible(True) + + def update_best_match_list(self, new_best_matches: List[BestMatchUpdate]): + self.changes_best_matches_list.update_list(new_best_matches) + + def update_threshold_position_lists(self, threshold_position_updates: List[ThresholdPositionUpdate]): + self.threshold_position_changes_below_list.update_list(threshold_position_updates) + self.threshold_position_changes_above_list.update_list(threshold_position_updates) + + def hide(self): + super().hide() + self.suggestion_visualizer.hide() diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index dbac14e2..006032b5 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -1,24 +1,21 @@ import logging -from abc import ABC -from collections import Counter -import random + from typing import List import numpy as np from PyQt6 import QtGui from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon, QTextCursor -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy, \ - QSpacerItem +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ - CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, LABEL_FONT_BOLD, SUBHEADER_FONT, \ - BestMatchUpdate, NewlyAddedNuggetContext, VisualizationsProvidingItem -from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget, \ - EmbeddingVisualizerWindow + CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, NewlyAddedNuggetContext, \ + VisualizationsProvidingItem +from wannadb_ui.data_insights import DataInsightsArea +from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget logger = logging.getLogger(__name__) @@ -105,7 +102,7 @@ def __init__(self, interactive_matching_widget): self.layout.addWidget(self.description) # suggestion visualizer - self.visualize_area = VisualizationArea() + self.visualize_area = DataInsightsArea() self.layout.addWidget(self.visualize_area) self.visualize_area.setVisible(False) self.visualizations = True @@ -132,18 +129,20 @@ def update_nuggets(self, feedback_request): attribute = feedback_request["attribute"] current_threshold = feedback_request["max-distance"] threshold_change = feedback_request["max-distance-change"] + nugget_updates_context = feedback_request["nugget-updates-context"] self.description.setText( "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") self.visualize_area.update_threshold_value_label(current_threshold, threshold_change) - self.visualize_area.update_best_match_list(feedback_request["new-best-matches"]) + self.visualize_area.update_best_match_list(nugget_updates_context.best_match_updates) + self.visualize_area.update_threshold_position_lists(nugget_updates_context.threshold_position_updates) params = { "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), "max_distance": current_threshold, "other_best_guesses": feedback_nuggets, - "new-nuggets": feedback_request["new-nuggets"], + "new-nuggets": nugget_updates_context.newly_added_nugget_contexts, "num-feedback": feedback_request["num-feedback"] } @@ -753,116 +752,3 @@ def enable_input(self): def disable_input(self): pass - - -class VisualizationArea(QWidget): - def __init__(self): - super(VisualizationArea, self).__init__() - - self.layout = QVBoxLayout(self) - self.layout.setSpacing(0) - self.layout.setContentsMargins(0, 0, 0, 0) - - self.title_label = QLabel("Data Insights") - self.title_label.setFont(SUBHEADER_FONT) - self.title_label.setContentsMargins(0, 5, 0, 5) - self.layout.addWidget(self.title_label) - - self.threshold_label = QLabel() - self.threshold_label.setFont(LABEL_FONT) - self.threshold_label.setText("Current Threshold: ") - self.threshold_value_label = QLabel() - self.threshold_value_label.setFont(LABEL_FONT) - self.threshold_change_label = QLabel() - self.threshold_change_label.setFont(LABEL_FONT) - self.threshold_hbox = QHBoxLayout() - self.threshold_hbox.setContentsMargins(0, 0, 0, 0) - self.threshold_hbox.setSpacing(0) - self.threshold_hbox.addWidget(self.threshold_label) - self.threshold_hbox.addWidget(self.threshold_value_label) - self.threshold_hbox.addWidget(self.threshold_change_label) - self.threshold_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) - self.layout.addLayout(self.threshold_hbox) - - self.visualizer_hbox = QHBoxLayout() - self.visualizer_hbox.setContentsMargins(0, 0, 0, 0) - self.visualizer_hbox.setSpacing(10) - - self.suggestion_visualizer = EmbeddingVisualizerWindow() - self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") - self.suggestion_visualizer_button.setFont(BUTTON_FONT) - self.suggestion_visualizer_button.setMaximumWidth(240) - self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) - - self.changes_best_matches_list = ChangedBestMatchDocumentsList() - - self.visualizer_hbox.addWidget(self.changes_best_matches_list) - self.visualizer_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) - self.visualizer_hbox.addWidget(self.suggestion_visualizer_button) - - self.layout.addLayout(self.visualizer_hbox) - - def _show_suggestion_visualizer(self): - self.suggestion_visualizer.setVisible(True) - - def update_threshold_value_label(self, new_threshold_value, threshold_value_change): - if round(threshold_value_change, 4) != 0: - self.threshold_value_label.setStyleSheet("color: yellow;") - change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' - self.threshold_change_label.setText(change_text) - else: - self.threshold_value_label.setStyleSheet("") - self.threshold_change_label.setText("") - - self.threshold_value_label.setText(f"{round(new_threshold_value, 4)} ") - self.threshold_label.setVisible(True) - - def update_best_match_list(self, new_best_matches: Counter[str]): - self.changes_best_matches_list.update_list(new_best_matches) - - def hide(self): - super().hide() - self.suggestion_visualizer.hide() - - -class ChangedBestMatchDocumentsList(QWidget): - def __init__(self): - super(ChangedBestMatchDocumentsList, self).__init__() - - self.layout = QHBoxLayout(self) - self.layout.setSpacing(0) - self.layout.setContentsMargins(0, 0, 0, 0) - - self._info_label = QLabel("Changed best matches:") - self._info_label.setContentsMargins(0, 0, 5, 0) - self._list_labels = [] - self.layout.addWidget(self._info_label) - - def update_list(self, best_match_updates: List[BestMatchUpdate]): - self._reset_list() - - if len(best_match_updates) == 0: - no_changes_label = QLabel("") - self.layout.addWidget(no_changes_label) - self._list_labels.append(no_changes_label) - return - - for best_match_update in random.choices(best_match_updates, k=min(5, len(best_match_updates))): - label_text = f"{best_match_update.new_best_match} ({best_match_update.count})" - label = QLabel(label_text) - label.setContentsMargins(0, 0, 5, 0) - label.setToolTip(f"Previous best match was: {best_match_update.old_best_match}\n" - f"Changes to token \"{best_match_update.new_best_match}\": {best_match_update.count}") - self.layout.addWidget(label) - self._list_labels.append(label) - - if len(best_match_updates) > 5: - last_label = QLabel(f"... and {len(best_match_updates) - 5} more.") - self.layout.addWidget(last_label) - self._list_labels.append(last_label) - - def _reset_list(self): - for list_label in self._list_labels: - self.layout.removeWidget(list_label) - - self._list_labels = [] From 6b7255bffa40b6560cc2989521e8ace16b13a30f Mon Sep 17 00:00:00 2001 From: Dongtaes Date: Thu, 15 Aug 2024 14:11:35 +0200 Subject: [PATCH 42/85] track match/no_match button --- wannadb_ui/interactive_matching.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 006032b5..2713619b 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -16,6 +16,8 @@ VisualizationsProvidingItem from wannadb_ui.data_insights import DataInsightsArea from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget +from wannadb_ui.study import Tracker, track_button_click + logger = logging.getLogger(__name__) @@ -299,6 +301,7 @@ def update_item(self, item, params=None): # self.info_button.setText(f"{str(round(self.nugget[CachedDistanceSignal], 2)).ljust(4)}") + @track_button_click(button_name= "nugget_list_match_button") def _match_button_clicked(self): self.nugget_list_widget.interactive_matching_widget.main_window.give_feedback_task({ "message": "is-match", @@ -463,6 +466,7 @@ def __init__(self, interactive_matching_widget): self.match_button.clicked.connect(self._match_button_clicked) self.buttons_widget_layout.addWidget(self.match_button) + @track_button_click(button_name= "document_match_button") def _match_button_clicked(self): if self.current_nugget is None: logger.info("Confirm custom nugget!") @@ -480,6 +484,7 @@ def _match_button_clicked(self): "not-a-match": None if self.current_nugget is self.original_nugget else self.original_nugget }) + @track_button_click(button_name= "document_no_match_button") def _no_match_button_clicked(self): self.interactive_matching_widget.main_window.give_feedback_task({ "message": "no-match-in-document", From 36a054fcae563025e265f160d509dce84c3078a0 Mon Sep 17 00:00:00 2001 From: Dongtaes Date: Sat, 17 Aug 2024 21:21:36 +0200 Subject: [PATCH 43/85] added accessibility button with IBM color palette --- wannadb_ui/data_insights.py | 9 ++++ wannadb_ui/interactive_matching.py | 23 ++++++++++ wannadb_ui/main_window.py | 43 ++++++++++++++++--- wannadb_ui/visualizations.py | 67 +++++++++++++++++++++++++----- 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index dd8441e3..153cafc9 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -182,6 +182,7 @@ def __init__(self): self.suggestion_visualizer = EmbeddingVisualizerWindow() self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") self.suggestion_visualizer_button.setContentsMargins(0, 0, 0, 0) + self.accessible_color_palette = False self.suggestion_visualizer_button.setFont(BUTTON_FONT) self.suggestion_visualizer_button.setMaximumWidth(240) self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) @@ -198,6 +199,14 @@ def __init__(self): def _show_suggestion_visualizer(self): self.suggestion_visualizer.setVisible(True) + + def enable_accessible_color_palette(self): + self.accessible_color_palette = True + self.suggestion_visualizer.enable_accessible_color_palette_() + + def disable_accessible_color_palette(self): + self.accessible_color_palette = False + self.suggestion_visualizer.disable_accessible_color_palette_() def update_threshold_value_label(self, new_threshold_value, threshold_value_change): if round(threshold_value_change, 4) != 0: diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 2713619b..aa638c29 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -77,6 +77,14 @@ def show_document_widget(self): self.layout.addWidget(self.document_widget) self.stop_button.hide() + def enable_accessible_color_palette(self): + self.document_widget.enable_accessible_color_palette() + self.nugget_list_widget.enable_accessible_color_palette() + + def disable_accessible_color_palette(self): + self.document_widget.disable_accessible_color_palette() + self.nugget_list_widget.disable_accessible_color_palette() + def enable_visualizations(self): self.document_widget.show_visualizations() self.nugget_list_widget.show_visualizations() @@ -108,6 +116,7 @@ def __init__(self, interactive_matching_widget): self.layout.addWidget(self.visualize_area) self.visualize_area.setVisible(False) self.visualizations = True + self.accessible_color_palette = False # nugget list self.num_nuggets_above_label = QLabel("") @@ -171,6 +180,14 @@ def update_nuggets(self, feedback_request): def enable_input(self): self.nugget_list.enable_input() + + def enable_accessible_color_palette(self): + self.accessible_color_palette = True + self.visualize_area.enable_accessible_color_palette() + + def disable_accessible_color_palette(self): + self.accessible_color_palette = False + self.visualize_area.disable_accessible_color_palette() def disable_input(self): self.nugget_list.disable_input() @@ -661,6 +678,12 @@ def disable_input(self): def update_attribute(self, attribute): self.current_attribute = attribute + def enable_accessible_color_palette(self): + self.visualizer.enable_accessible_color_palette() + + def disable_accessible_color_palette(self): + self.visualizer.enable_accessible_color_palette() + def show_visualizations(self): self.upper_buttons_widget.show() self.visualizer.show() diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index eb51bd2c..6da94e84 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -1,6 +1,7 @@ import enum import logging import re +import wannadb_ui.visualizations as visualizations from PyQt6.QtCore import QMutex, Qt, QThread, QWaitCondition, pyqtSignal, pyqtSlot from PyQt6.QtGui import QAction, QIcon @@ -255,6 +256,20 @@ def disable_visualizations_task(self): self.enable_visualizations_action.setEnabled(True) self.disable_visualizations_action.setEnabled(False) + def enable_accessible_color_palette_task(self): + logger.info("Execute task 'enable_accessible_color_palette_task'.") + self.accessible_color_palette = True + self.interactive_matching_widget.enable_accessible_color_palette() + self.enable_accessible_color_palette_action.setEnabled(False) + self.disable_accessible_color_palette_action.setEnabled(True) + + def disable_accessible_color_palette_task(self): + logger.info("Execute task 'disable_accessible_color_palette_task'.") + self.accessible_color_palette = False + self.interactive_matching_widget.disable_accessible_color_palette() + self.enable_accessible_color_palette_action.setEnabled(True) + self.disable_accessible_color_palette_action.setEnabled(False) + def show_document_base_creator_widget_task(self): logger.info("Execute task 'show_document_base_creator_widget_task'.") @@ -300,7 +315,7 @@ def give_feedback_task(self, feedback): self.feedback_cond.wakeAll() self._enable_visualization_settings() - + self._enable_color_palette_settings() def interactive_table_population_task(self): logger.info("Execute task 'interactive_table_population_task'.") @@ -356,7 +371,7 @@ def to_start_state(self): self.enable_collect_statistics_action.setEnabled(True) self._enable_visualization_settings() - + self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.document_base_viewer_widget.hide() self.document_base_creator_widget.hide() @@ -389,7 +404,7 @@ def to_create_document_base_state(self): self.enable_collect_statistics_action.setEnabled(True) self._enable_visualization_settings() - + self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() self.document_base_creation_widget.hide() @@ -411,7 +426,7 @@ def to_creating_document_base_state(self): self.disable_global_input() self._enable_visualization_settings() - + self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() self.document_base_creator_widget.hide() @@ -456,7 +471,7 @@ def to_view_document_base_state(self): self.enable_collect_statistics_action.setEnabled(True) self._enable_visualization_settings() - + self._enable_color_palette_settings() self.document_base_viewer_widget.enable_input() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -487,7 +502,7 @@ def to_interactive_matching_state(self): self.enable_collect_statistics_action.setEnabled(True) self._enable_visualization_settings() - + self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() self.document_base_viewer_widget.hide() @@ -506,6 +521,9 @@ def _enable_visualization_settings(self): self.enable_visualizations_action.setEnabled(not self.visualizations) self.disable_visualizations_action.setEnabled(self.visualizations) + def _enable_color_palette_settings(self): + self.enable_accessible_color_palette_action.setEnabled(not self.accessible_color_palette) + self.disable_accessible_color_palette_action.setEnabled(self.accessible_color_palette) # noinspection PyUnresolvedReferences def __init__(self) -> None: super(MainWindow, self).__init__() @@ -517,6 +535,7 @@ def __init__(self) -> None: self.statistics = None self.collect_statistics = True self.visualizations = True + self.accessible_color_palette = False self.attributes_to_match = None self.cache_db = None @@ -654,6 +673,16 @@ def __init__(self) -> None: self.disable_visualizations_action.setStatusTip("Disable visualization widgets.") self.disable_visualizations_action.triggered.connect(self.disable_visualizations_task) self._all_actions.append(self.disable_visualizations_action) + + self.enable_accessible_color_palette_action = QAction("&Enable accessible palette", self) + self.enable_accessible_color_palette_action.setStatusTip("Change the color palette to accessible.") + self.enable_accessible_color_palette_action.triggered.connect(self.enable_accessible_color_palette_task) + self._all_actions.append(self.enable_accessible_color_palette_action) + + self.disable_accessible_color_palette_action = QAction("&Disable accessible palette", self) + self.disable_accessible_color_palette_action.setStatusTip("Change the color palette to rgb.") + self.disable_accessible_color_palette_action.triggered.connect(self.disable_accessible_color_palette_task) + self._all_actions.append(self.disable_accessible_color_palette_action) # set up the menu bar self.menubar = self.menuBar() @@ -694,6 +723,8 @@ def __init__(self) -> None: self.visualizations_menu.setFont(MENU_FONT) self.visualizations_menu.addAction(self.enable_visualizations_action) self.visualizations_menu.addAction(self.disable_visualizations_action) + self.visualizations_menu.addAction(self.enable_accessible_color_palette_action) + self.visualizations_menu.addAction(self.disable_accessible_color_palette_action) # main UI self.central_widget = QWidget(self) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 1ba41458..f9e57860 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -25,11 +25,16 @@ logger: logging.Logger = logging.getLogger(__name__) RED = pg.mkColor('red') +ACC_RED = pg.mkColor(220, 38, 127) BLUE = pg.mkColor('blue') +ACC_BLUE = pg.mkColor(100, 143, 255) GREEN = pg.mkColor('green') +ACC_GREEN = pg.mkColor(255, 176, 0) WHITE = pg.mkColor('white') YELLOW = pg.mkColor('yellow') +ACC_YELLOW = pg.mkColor(254, 97, 0) PURPLE = pg.mkColor('purple') +ACC_PURPLE = pg.mkColor(120,94,240) EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) DEFAULT_NUGGET_SIZE = 7 @@ -70,7 +75,8 @@ def __init__(self, nuggets: List[InformationNugget] = None, currently_highlighted_nugget: InformationNugget = None, best_guess: InformationNugget = None, - other_best_guesses: List[InformationNugget] = None): + other_best_guesses: List[InformationNugget] = None, + accessible_color_palette: bool = False): self._attribute: Attribute = attribute self._nuggets: List[InformationNugget] = nuggets self._currently_highlighted_nugget: InformationNugget = currently_highlighted_nugget @@ -79,6 +85,13 @@ def __init__(self, self._nugget_to_displayed_items: Dict[InformationNugget, Tuple[GLScatterPlotItem, GLTextItem]] = dict() self._nugget_to_similar_nugget: Dict[InformationNugget, Union[InformationNugget, None]] = dict() self._gl_widget = GLViewWidget() + self.accessible_color_palette = accessible_color_palette + + def enable_accessible_color_palette_(self): + self.accessible_color_palette = True + + def disable_accessible_color_palette_(self): + self.accessible_color_palette = False def update_and_display_params(self, attribute: Attribute, @@ -150,7 +163,7 @@ def highlight_best_guess(self, best_guess: InformationNugget): self._best_guess = best_guess if self._best_guess == self._currently_highlighted_nugget: - self._highlight_nugget(self._best_guess, BLUE, 15) + self._highlight_nugget(self._best_guess, ACC_BLUE if self.accessible_color_palette else BLUE, 15) return self._highlight_nugget(self._best_guess, WHITE, 15) @@ -185,7 +198,7 @@ def display_nugget_embeddings(self, nuggets): annotation_text=build_nuggets_annotation_text(nugget)) def display_attribute_embedding(self, attribute): - self.add_item_to_grid(nugget_to_display_context=(attribute, RED), + self.add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self.accessible_color_palette else RED), annotation_text=attribute.name) self._attribute = attribute # save for later use @@ -208,7 +221,7 @@ def highlight_confirmed_matches(self): for confirmed_match in self._attribute.confirmed_matches: if confirmed_match in self._nugget_to_displayed_items: - self._highlight_nugget(confirmed_match, GREEN, DEFAULT_NUGGET_SIZE) + self._highlight_nugget(confirmed_match, ACC_GREEN if self.accessible_color_palette else GREEN, DEFAULT_NUGGET_SIZE) def reset(self): for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): @@ -230,7 +243,7 @@ def _determine_update_values(self, previously_selected_nugget, newly_selected_nu similar_newly_selected_nugget = self._nugget_to_similar_nugget[newly_selected_nugget] \ if previously_selected_nugget in self._nugget_to_similar_nugget else None - highlight_color = BLUE + highlight_color = ACC_BLUE if self.accessible_color_palette else BLUE highlight_size = 15 if newly_selected_nugget == self._best_guess or similar_newly_selected_nugget == self._best_guess \ else 10 @@ -239,7 +252,7 @@ def _determine_update_values(self, previously_selected_nugget, newly_selected_nu reset_size = DEFAULT_NUGGET_SIZE elif (previously_selected_nugget in self._attribute.confirmed_matches or similar_prev_selected_nugget in self._attribute.confirmed_matches): - reset_color = GREEN + reset_color = ACC_GREEN if self.accessible_color_palette else GREEN reset_size = DEFAULT_NUGGET_SIZE elif previously_selected_nugget == self._best_guess or similar_prev_selected_nugget == self._best_guess: reset_color = WHITE @@ -255,14 +268,14 @@ def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: CurrentThresholdSignal.identifier not in self._attribute.signals): logger.warning(f"Could not determine nuggets color from given attribute: {self._attribute}. " f"Will return purple as color highlighting nuggets with this issue.") - return PURPLE + return ACC_PURPLE if self.accessible_color_palette else PURPLE similar_nugget = self._nugget_to_similar_nugget[nugget] if nugget in self._nugget_to_similar_nugget else None return (WHITE if nugget[CachedDistanceSignal] < self._attribute[CurrentThresholdSignal] or (similar_nugget is not None and similar_nugget[CachedDistanceSignal] < self._attribute[ CurrentThresholdSignal]) - else RED) + else ACC_RED if self.accessible_color_palette else RED) def _add_grids(self): grid_xy = gl.GLGridItem() @@ -286,7 +299,7 @@ def _highlight_nugget(self, nugget_to_highlight, new_color, new_size): scatter_to_highlight.setData(color=new_color, size=new_size) def _add_other_best_guess(self, other_best_guess): - self.add_item_to_grid(nugget_to_display_context=(other_best_guess, YELLOW), + self.add_item_to_grid(nugget_to_display_context=(other_best_guess, ACC_YELLOW if self.accessible_color_palette else YELLOW), annotation_text=build_nuggets_annotation_text(other_best_guess), size=15) @@ -297,9 +310,11 @@ def __init__(self, nuggets: List[InformationNugget] = None, currently_highlighted_nugget: InformationNugget = None, best_guess: InformationNugget = None, - other_best_guesses: List[InformationNugget] = None): - EmbeddingVisualizer.__init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess) + other_best_guesses: List[InformationNugget] = None, + accessible_color_palette: bool = False): + EmbeddingVisualizer.__init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess, accessible_color_palette) QMainWindow.__init__(self) + self.accessible_color_palette = accessible_color_palette self.setWindowTitle("3D Grid Visualizer") self.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) @@ -326,6 +341,14 @@ def showEvent(self, event): super().showEvent(event) Tracker().start_timer(str(self.__class__)) + def _enable_accessible_color_palette(self): + self.accessible_color_palette = True + self.enable_accessible_color_palette_() + + def _disable_accessible_color_palette(self): + self.accessible_color_palette = False + self.disable_accessible_color_palette_() + def closeEvent(self, event): Tracker().stop_timer(str(self.__class__)) event.accept() @@ -361,6 +384,7 @@ def __init__(self): self.remove_other_best_guesses_button.clicked.connect(self._handle_remove_other_best_guesses_clicked) self.best_guesses_widget_layout.addWidget(self.remove_other_best_guesses_button) self.layout.addWidget(self.best_guesses_widget) + self.accessible_color_palette = False self._add_grids() @@ -376,6 +400,20 @@ def _show_embedding_visualizer_window(self): best_guess=self._best_guess) self._fullscreen_window.show() + def enable_accessible_color_palette(self): + self.accessible_color_palette = True + if self._fullscreen_window is None: + pass + else: + self._fullscreen_window.enable_accessible_color_palette_() + + def disable_accessible_color_palette(self): + self.accessible_color_palette = False + if self._fullscreen_window is None: + pass + else: + self._fullscreen_window.disable_accessible_color_palette_() + def return_from_embedding_visualizer_window(self): self._fullscreen_window.close() self._fullscreen_window = None @@ -578,6 +616,13 @@ def __init__(self, parent=None): self.distances = None self.y = None self.scatter = None + self.accessible_color_palette = False + + def enable_accessible_color_palette(self): + self.accessible_color_palette = True + + def disable_accessible_color_palette(self): + self.accessible_color_palette = False def update_data(self, nuggets): self.reset() From 20c3d2f73450f334c5de20cdcb93d8dc1f036b5f Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 7 Aug 2024 17:33:16 +0200 Subject: [PATCH 44/85] Logging ready for show bar chart, show scatter plot, and embedding visualizer --- wannadb_ui/study.py | 33 +++++++++++++++++++-------------- wannadb_ui/visualizations.py | 27 +++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index e9b91728..4dc75441 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -23,13 +23,14 @@ def __new__(cls, *args, **kwargs): def __init__(self): if not self._initialized: super().__init__() # Call the QObject initializer - self.window_open_time = None + self.window_open_times = {} self.timer = QTimer() - self.timer.timeout.connect(self.calculate_time_spent) + #self.timer.timeout.connect(self.calculate_time_spent) self.button_click_counts: Dict[str, int] = {} - self.total_window_open_time = {} + self.total_window_open_times = {} self._initialized = True self.log = '' + self.sequence_number = 1 def dump_report(self): tick: float = time.time() @@ -44,7 +45,7 @@ def dump_report(self): for button_name, number_of_clicks in self.button_click_counts.items(): file.write(f"\t'{button_name}' button has been clicked {number_of_clicks} times\n") file.write(f"Window Information:\n") - for window_name, time_open_in_sec in self.total_window_open_time.items(): + for window_name, time_open_in_sec in self.total_window_open_times.items(): file.write(f"\t{window_name} was open for a total of {time_open_in_sec} seconds\n") tack: float = time.time() logger.info(f"Writing the report in {round(tick - tack, 2)} seconds") @@ -52,9 +53,10 @@ def dump_report(self): #todo 1 timer works for many windows (not just for one) #todo 2 timer works for many windows simultaneously def start_timer(self, window_name: str): - self.window_open_time = QDateTime.currentDateTime() + self.window_open_times[window_name] = QDateTime.currentDateTime() self.timer.start(1000) - self.log += f"{window_name} was opened" + self.log += f"{self.sequence_number}. {window_name} was opened\n" + self.sequence_number += 1 def stop_timer(self, window_name: str): self.timer.stop() @@ -62,23 +64,26 @@ def stop_timer(self, window_name: str): self.calculate_time_spent(window_name) def calculate_time_spent(self, window_name: str): - if self.window_open_time: + print(f"calculate_time_spent called with {window_name}") + if self.window_open_times[window_name]: current_time = QDateTime.currentDateTime() - time_spent = self.window_open_time.msecsTo(current_time) / 1000.0 # Convert to seconds + time_spent = self.window_open_times[window_name].msecsTo(current_time) / 1000.0 # Convert to seconds self.time_spent_signal.emit(window_name, time_spent) - self.window_open_time = None - if window_name in self.total_window_open_time: - self.total_window_open_time[window_name] += time_spent + self.window_open_times[window_name] = None + if window_name in self.total_window_open_times: + self.total_window_open_times[window_name] += time_spent else: - self.total_window_open_time[window_name] = time_spent - self.log += f'{window_name} was closed. Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' + self.total_window_open_times[window_name] = time_spent + self.log += f'{self.sequence_number}. {window_name} was closed. Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' + self.sequence_number += 1 def track_button_click(self, button_name: str): if button_name in self.button_click_counts: self.button_click_counts[button_name] += 1 else: self.button_click_counts[button_name] = 1 - self.log += f'{button_name} was clicked.\n' + self.log += f'{self.sequence_number}. {button_name} was clicked.\n' + self.sequence_number += 1 def get_button_click_count(self, button_name: str) -> int: return self.button_click_counts.get(button_name, 0) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index f9e57860..94bcd815 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -480,7 +480,7 @@ def __init__(self, parent=None): self.layout.addWidget(self.button) self.data = [] self.button.clicked.connect(self.show_bar_chart) - self.window = None + self.window : QMainWindow = None self.current_annotation_index = None self.bar = None @@ -546,6 +546,9 @@ def plot_bar_chart(self): scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.window = QMainWindow() + self.window.closeEvent = self.closeWindowEvent + self.window.showEvent = self.showWindowEvent + self.window.setWindowTitle("Bar Chart") self.window.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) self.window.setCentralWidget(scroll_area) @@ -590,13 +593,13 @@ def reset(self): self.data = [] self.bar = None - def showEvent(self, event): + def showWindowEvent(self, event): super().showEvent(event) Tracker().start_timer(str(self.__class__)) - def closeEvent(self, event): - Tracker().stop_timer(str(self.__class__)) + def closeWindowEvent(self, event): event.accept() + Tracker().stop_timer(str(self.__class__)) class ScatterPlotVisualizerWidget(QWidget): @@ -644,6 +647,7 @@ def reset(self): self.window = None self.annotation = None + @track_button_click("show scatter plot") def show_scatter_plot(self): if not self.data: return @@ -704,7 +708,10 @@ def show_scatter_plot(self): # Create a new window for the plot self.window = QMainWindow() + self.window.closeEvent = self.closeWindowEvent + self.window.showEvent = self.showWindowEvent self.window.setWindowTitle("Scatter Plot") + self.window.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) # Set the central widget of the window to the canvas @@ -747,3 +754,15 @@ def on_pick(self, event): self.annotation.set_text(text) self.annotation.set_visible(True) self.scatter_plot_canvas.draw_idle() + + def reset(self): + self.data = [] + self.bar = None + + def showWindowEvent(self, event): + super().showEvent(event) + Tracker().start_timer(str(self.__class__)) + + def closeWindowEvent(self, event): + event.accept() + Tracker().stop_timer(str(self.__class__)) \ No newline at end of file From 77e25db026da10503a55bec9e928f7e22c23d02f Mon Sep 17 00:00:00 2001 From: nils-bz Date: Thu, 15 Aug 2024 21:48:58 +0200 Subject: [PATCH 45/85] fix issue with match update list --- wannadb/matching/matching.py | 3 +++ wannadb_ui/data_insights.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 46f0376c..5d599027 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -561,6 +561,9 @@ def run_nugget_pipeline(nuggets): best_guesses.append((document.name, None)) statistics[attribute.name]["best_guesses"].append((num_feedback, best_guesses)) + if self._old_max_distance == -1: + self._old_max_distance = self._max_distance + tak: float = time.time() logger.info(f"Executed interactive matching in {tak - tik} seconds.") diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 153cafc9..d6b2ddad 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -73,7 +73,7 @@ def __init__(self): super(ChangedBestMatchDocumentsList, self).__init__("Changed best guesses:", tooltip_text) def _create_label_and_tooltip_text(self, update: BestMatchUpdate) -> Tuple[str, str]: - label_text = f"{update.new_best_match} ({update.count})" + label_text = f"{update.new_best_match} {'(' + str(update.count) + ')' if update.count > 1 else ''}" tooltip_text = (f"Previous best match was: {update.old_best_match}\n" f"Changes to token \"{update.new_best_match}\": {update.count}") From 7be5d8cfffc71b83befa8091d783f0c4e4df4012 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 17 Aug 2024 01:33:36 +0200 Subject: [PATCH 46/85] add dimension reducer to preprocess script --- scripts/preprocess.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/preprocess.py b/scripts/preprocess.py index 32fd4ab9..f8df1bc9 100644 --- a/scripts/preprocess.py +++ b/scripts/preprocess.py @@ -6,6 +6,7 @@ from wannadb.configuration import Pipeline from wannadb.data.data import Document, DocumentBase from wannadb.interaction import EmptyInteractionCallback +from wannadb.preprocessing.dimension_reduction import PCAReducer from wannadb.preprocessing.embedding import BERTContextSentenceEmbedder, RelativePositionEmbedder, SBERTTextEmbedder, SBERTLabelEmbedder from wannadb.preprocessing.extraction import StanzaNERExtractor, SpacyNERExtractor from wannadb.preprocessing.label_paraphrasing import OntoNotesLabelParaphraser, SplitAttributeNameLabelParaphraser @@ -68,7 +69,8 @@ def main() -> None: SBERTLabelEmbedder("SBERTBertLargeNliMeanTokensResource"), SBERTTextEmbedder("SBERTBertLargeNliMeanTokensResource"), BERTContextSentenceEmbedder("BertLargeCasedResource"), - RelativePositionEmbedder() + RelativePositionEmbedder(), + PCAReducer() ]) document_base = DocumentBase(documents, []) From 740424f5fe2620d2dbf60a02e043ca00b17d46a4 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 17 Aug 2024 02:16:36 +0200 Subject: [PATCH 47/85] remove duplicated nuggets in preprocessing phase --- scripts/preprocess.py | 3 +- wannadb/data/data.py | 42 +++++++++++++-- wannadb/preprocessing/other_processing.py | 63 ++++++++++++++++++++++- wannadb/utils.py | 27 ++++++++++ wannadb_ui/visualizations.py | 40 +------------- wannadb_ui/wannadb_api.py | 1 + 6 files changed, 132 insertions(+), 44 deletions(-) create mode 100644 wannadb/utils.py diff --git a/scripts/preprocess.py b/scripts/preprocess.py index f8df1bc9..564bf924 100644 --- a/scripts/preprocess.py +++ b/scripts/preprocess.py @@ -11,7 +11,7 @@ from wannadb.preprocessing.extraction import StanzaNERExtractor, SpacyNERExtractor from wannadb.preprocessing.label_paraphrasing import OntoNotesLabelParaphraser, SplitAttributeNameLabelParaphraser from wannadb.preprocessing.normalization import CopyNormalizer -from wannadb.preprocessing.other_processing import ContextSentenceCacher +from wannadb.preprocessing.other_processing import ContextSentenceCacher, DuplicatedNuggetsCleaner from wannadb.resources import ResourceManager from wannadb.statistics import Statistics from wannadb.status import EmptyStatusCallback @@ -70,6 +70,7 @@ def main() -> None: SBERTTextEmbedder("SBERTBertLargeNliMeanTokensResource"), BERTContextSentenceEmbedder("BertLargeCasedResource"), RelativePositionEmbedder(), + DuplicatedNuggetsCleaner(), PCAReducer() ]) diff --git a/wannadb/data/data.py b/wannadb/data/data.py index a1ba66fa..c87e25c1 100644 --- a/wannadb/data/data.py +++ b/wannadb/data/data.py @@ -6,7 +6,8 @@ import bson from wannadb.data import signals -from wannadb.data.signals import BaseSignal, ValueSignal +from wannadb.data.signals import BaseSignal, ValueSignal, TextEmbeddingSignal, CurrentMatchIndexSignal +from wannadb.utils import embeddings_equal, get_possible_duplicate logger: logging.Logger = logging.getLogger(__name__) @@ -131,6 +132,19 @@ def __setitem__(self, key: Union[str, Type[BaseSignal]], value: Union[BaseSignal else: # signal not already set and value is not a signal object ==> get signal class by id and create object self._signals[signal_identifier] = signals.SIGNALS[signal_identifier](value) + def duplicates(self, other) -> bool: + if not isinstance(other, InformationNugget): + return False + + if (TextEmbeddingSignal.identifier not in self._signals or + TextEmbeddingSignal.identifier not in other._signals): + return False + + return (self.document.name == other.document.name and + self._start_char == other._start_char and + self._end_char == other._end_char and + embeddings_equal(self[TextEmbeddingSignal], other[TextEmbeddingSignal])) + class Attribute: """ @@ -275,6 +289,10 @@ def nuggets(self) -> List[InformationNugget]: """Nuggets obtained from the document.""" return self._nuggets + @nuggets.setter + def nuggets(self, nuggets: List[InformationNugget]) -> None: + self._nuggets = nuggets + @property def attribute_mappings(self) -> Dict[str, List[InformationNugget]]: """Mappings between attribute names and lists of nuggets associated with them.""" @@ -298,7 +316,8 @@ def sentences(self) -> List[str]: def sentence(self, idx: int) -> tuple[int, int, str]: """Sentence of the document at the given index.""" start_char = self['SentenceStartCharsSignal'][idx] - end_char = self['SentenceStartCharsSignal'][idx + 1] if idx + 1 < len(self['SentenceStartCharsSignal']) else len(self.text) + end_char = self['SentenceStartCharsSignal'][idx + 1] if idx + 1 < len( + self['SentenceStartCharsSignal']) else len(self.text) return start_char, end_char, self.text[start_char:end_char] def __getitem__(self, item: Union[str, Type[BaseSignal]]) -> Any: @@ -685,6 +704,8 @@ def from_bson(cls, bson_bytes: bytes) -> "DocumentBase": # deserialize the document document: Document = Document(name=serialized_document["name"], text=serialized_document["text"]) + old_to_new_index: Dict[int, int] = dict() + old_index = 0 for serialized_nugget in serialized_document["nuggets"]: # deserialize the nugget nugget: InformationNugget = InformationNugget( @@ -698,15 +719,28 @@ def from_bson(cls, bson_bytes: bytes) -> "DocumentBase": signal: BaseSignal = BaseSignal.from_serializable(serialized_signal, signal_identifier) nugget.signals[signal_identifier] = signal - document.nuggets.append(nugget) + possible_duplicate, idx = get_possible_duplicate(nugget, document.nuggets) + if possible_duplicate is None: + old_to_new_index[old_index] = len(document.nuggets) + document.nuggets.append(nugget) + else: + old_to_new_index[old_index] = idx + + old_index += 1 # deserialize the attribute mappings for name, indices in serialized_document["attribute_mappings"].items(): - document.attribute_mappings[name] = [document.nuggets[idx] for idx in indices] + document.attribute_mappings[name] = [document.nuggets[old_to_new_index[idx]] for idx in indices] # deserialize the signals for signal_identifier, serialized_signal in serialized_document["signals"].items(): signal: BaseSignal = BaseSignal.from_serializable(serialized_signal, signal_identifier) + if signal_identifier == CurrentMatchIndexSignal.identifier: + # As this an index related signal and the original indices changed due to removing duplicated + # nuggets, we need to adapt this signals value to the new indices + old_index = signal.value + signal = CurrentMatchIndexSignal(old_to_new_index[old_index]) + document.signals[signal_identifier] = signal document_base.documents.append(document) diff --git a/wannadb/preprocessing/other_processing.py b/wannadb/preprocessing/other_processing.py index cddb6ebc..53781347 100644 --- a/wannadb/preprocessing/other_processing.py +++ b/wannadb/preprocessing/other_processing.py @@ -4,10 +4,11 @@ from wannadb.configuration import BasePipelineElement, register_configurable_element from wannadb.data.data import DocumentBase, InformationNugget from wannadb.data.signals import CachedContextSentenceSignal, \ - SentenceStartCharsSignal + SentenceStartCharsSignal, TextEmbeddingSignal, CurrentMatchIndexSignal from wannadb.interaction import BaseInteractionCallback from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback +from wannadb.utils import get_possible_duplicate logger: logging.Logger = logging.getLogger(__name__) @@ -80,3 +81,63 @@ def to_config(self) -> Dict[str, Any]: @classmethod def from_config(cls, config: Dict[str, Any]) -> "ContextSentenceCacher": return cls() + + +@register_configurable_element +class DuplicatedNuggetsCleaner(BasePipelineElement): + """ + Removes duplicated nuggets. + We consider a nugget duplicating another nugget if they belong to the same document, are located at the same + position within the documents text and have "nearly" the same embedding. "Nearly" in this context refers to a + tolerance value which is required while comparing two nugget embeddings as embedding values are represented as + floats and therefore can't be compared for exact equality. For more details see + :func`~wannadb.utils.embeddings_equal` + """ + + identifier: str = "DuplicatedNuggetsCleaner" + + required_signal_identifiers: Dict[str, List[str]] = { + "nuggets": [TextEmbeddingSignal.identifier], + "attributes": [], + "documents": [] + } + + def __init__(self): + """Initialize the DuplicatedNuggetsCleaner.""" + super(DuplicatedNuggetsCleaner, self).__init__() + logger.debug(f"Initialized '{self.identifier}'.") + + def _call(self, document_base: DocumentBase, interaction_callback: BaseInteractionCallback, + status_callback: BaseStatusCallback, statistics: Statistics) -> None: + for document in document_base.documents: + + cleaned_nuggets: List[InformationNugget] = list() + old_to_new_index: Dict[int, int] = dict() + old_index = 0 + + for nugget in document_base.nuggets: + possible_duplicate, idx = get_possible_duplicate(nugget, document.nuggets) + if possible_duplicate is None: + old_to_new_index[old_index] = len(cleaned_nuggets) + cleaned_nuggets.append(nugget) + else: + old_to_new_index[old_index] = idx + + old_index += 1 + + logger.info(f"Removed {len(document.nuggets) - len(cleaned_nuggets)} duplicated nuggets from document " + f"\"{document.name}\".") + document.nuggets = cleaned_nuggets + + if CurrentMatchIndexSignal.identifier in document.signals: + old_index = document[CurrentMatchIndexSignal].value + document[CurrentMatchIndexSignal] = CurrentMatchIndexSignal(old_to_new_index[old_index]) + + def to_config(self) -> Dict[str, Any]: + return { + "identifier": self.identifier + } + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> "DuplicatedNuggetsCleaner": + return cls() diff --git a/wannadb/utils.py b/wannadb/utils.py new file mode 100644 index 00000000..f24d1058 --- /dev/null +++ b/wannadb/utils.py @@ -0,0 +1,27 @@ +import math + +import numpy as np + + +def get_possible_duplicate(nugget_to_check, nugget_list): + for idx, nugget in enumerate(nugget_list): + if nugget_to_check.duplicates(nugget): + return nugget, idx + + return None, None + + +def positions_equal(pos1: np.ndarray, pos2: np.ndarray) -> bool: + if pos1.shape != (1, 3) or pos2.shape != (1, 3): + return False + return (math.isclose(pos1[0][0], pos2[0][0], rel_tol=1e-05, abs_tol=1e-05) and + math.isclose(pos1[0][1], pos2[0][1], rel_tol=1e-05, abs_tol=1e-05) and + math.isclose(pos1[0][2], pos2[0][2], rel_tol=1e-05, abs_tol=1e-05)) + + +def embeddings_equal(embedding1: np.ndarray, embedding2: np.ndarray) -> bool: + if embedding1.shape != embedding2.shape: + return False + + arrays_are_close = np.vectorize(math.isclose) + return arrays_are_close(embedding1, embedding2, rel_tol=1e-05, abs_tol=1e-05).all() diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 94bcd815..7d69f6c8 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -52,15 +52,6 @@ def get_colors(distances, color_start='red', color_end='blue'): return colors -def positions_equal(pos1: np.ndarray, pos2: np.ndarray) -> bool: - if pos1.shape != (1, 3) or pos2.shape != (1, 3): - return False - - return (math.isclose(pos1[0][0], pos2[0][0], rel_tol=1e-05, abs_tol=1e-05) and - math.isclose(pos1[0][1], pos2[0][1], rel_tol=1e-05, abs_tol=1e-05) and - math.isclose(pos1[0][2], pos2[0][2], rel_tol=1e-05, abs_tol=1e-05)) - - def build_nuggets_annotation_text(nugget) -> str: return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" @@ -83,7 +74,6 @@ def __init__(self, self._best_guess: InformationNugget = best_guess self._other_best_guesses: List[InformationNugget] = other_best_guesses self._nugget_to_displayed_items: Dict[InformationNugget, Tuple[GLScatterPlotItem, GLTextItem]] = dict() - self._nugget_to_similar_nugget: Dict[InformationNugget, Union[InformationNugget, None]] = dict() self._gl_widget = GLViewWidget() self.accessible_color_palette = accessible_color_palette @@ -135,17 +125,6 @@ def add_item_to_grid(self, InformationNugget) \ else np.array([item_to_display[PCADimensionReducedLabelEmbeddingSignal]]) - # Check for already existing scatter at the same position representing same nugget. - # This can happen due to usage of different extractors. - for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): - if positions_equal(scatter.pos, position) and nugget.text == item_to_display.text: - logger.info( - f"{item_to_display} is already shown in the grid - probably it was extracted by multiple extractors" - f" - will not add again to grid.") - self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) - self._nugget_to_similar_nugget[item_to_display] = nugget - return - scatter = GLScatterPlotItem(pos=position, color=color, size=size, pxMode=True) annotation = GLTextItem(pos=[position[0][0], position[0][1], position[0][2]], color=WHITE, @@ -157,7 +136,6 @@ def add_item_to_grid(self, if isinstance(item_to_display, InformationNugget): self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) - self._nugget_to_similar_nugget[item_to_display] = None def highlight_best_guess(self, best_guess: InformationNugget): self._best_guess = best_guess @@ -206,11 +184,6 @@ def remove_nuggets_from_widget(self, nuggets_to_remove): for nugget in nuggets_to_remove: scatter, annotation = self._nugget_to_displayed_items.pop(nugget) - if nugget in self._nugget_to_similar_nugget and self._nugget_to_similar_nugget[nugget] is not None: - # This nugget is represented by same items as another nugget. - # Once this other nugget is processed, the corresponding items will be removed from grid - continue - self._gl_widget.removeItem(scatter) self._gl_widget.removeItem(annotation) @@ -225,27 +198,18 @@ def highlight_confirmed_matches(self): def reset(self): for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): - if nugget in self._nugget_to_similar_nugget and self._nugget_to_similar_nugget[nugget] is not None: - # Corresponding items will be removed once processing similar nugget - continue self._gl_widget.removeItem(scatter) self._gl_widget.removeItem(annotation) self._nugget_to_displayed_items = {} - self._nugget_to_similar_nugget = {} self._currently_highlighted_nugget = None self._best_guess = None def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( (int, Color), (int, Color)): - similar_prev_selected_nugget = self._nugget_to_similar_nugget[previously_selected_nugget] \ - if previously_selected_nugget in self._nugget_to_similar_nugget else None - similar_newly_selected_nugget = self._nugget_to_similar_nugget[newly_selected_nugget] \ - if previously_selected_nugget in self._nugget_to_similar_nugget else None highlight_color = ACC_BLUE if self.accessible_color_palette else BLUE - highlight_size = 15 if newly_selected_nugget == self._best_guess or similar_newly_selected_nugget == self._best_guess \ - else 10 + highlight_size = 15 if newly_selected_nugget == self._best_guess else 10 if previously_selected_nugget is None: reset_color = WHITE @@ -254,7 +218,7 @@ def _determine_update_values(self, previously_selected_nugget, newly_selected_nu similar_prev_selected_nugget in self._attribute.confirmed_matches): reset_color = ACC_GREEN if self.accessible_color_palette else GREEN reset_size = DEFAULT_NUGGET_SIZE - elif previously_selected_nugget == self._best_guess or similar_prev_selected_nugget == self._best_guess: + elif previously_selected_nugget == self._best_guess: reset_color = WHITE reset_size = 15 else: diff --git a/wannadb_ui/wannadb_api.py b/wannadb_ui/wannadb_api.py index 03d7a081..dfaaa4d6 100644 --- a/wannadb_ui/wannadb_api.py +++ b/wannadb_ui/wannadb_api.py @@ -127,6 +127,7 @@ def create_document_base(self, path, attribute_names, statistics): BERTContextSentenceEmbedder("BertLargeCasedResource"), SBERTDocumentSentenceEmbedder("SBERTBertLargeNliMeanTokensResource"), RelativePositionEmbedder(), + DuplicatedNuggetsCleaner(), PCAReducer(), TSNEReducer() ]) From 7dcd162933647bf472b77136f96778968578b4ce Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 17 Aug 2024 03:02:17 +0200 Subject: [PATCH 48/85] fix several issues with changes lists and some small UI improvements --- wannadb/matching/matching.py | 23 ++++++++++++++--------- wannadb_ui/data_insights.py | 6 ++++-- wannadb_ui/interactive_matching.py | 10 +++++++--- wannadb_ui/wannadb_api.py | 8 ++++---- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 5d599027..5010607f 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -310,6 +310,7 @@ def _sort_remaining_documents(): t1 = time.time() statistics[attribute.name]["feedback_durations"].append(t1 - t0) + self._old_max_distance = self._max_distance self._old_feedback_nuggets = feedback_nuggets self._new_nugget_contexts.clear() old_distances = {nugget: nugget[CachedDistanceSignal] for nugget in document_base.nuggets} @@ -411,13 +412,17 @@ def run_nugget_pipeline(nuggets): for nugget, new_distance in zip(document.nuggets, new_distances): if distances_based_on_label or new_distance < nugget[CachedDistanceSignal]: nugget[CachedDistanceSignal] = new_distance + + previous_best_match: InformationNugget = document.nuggets[document[CachedDistanceSignal]] for ix, nugget in enumerate(document.nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: document[CurrentMatchIndexSignal] = ix - if nugget.text != current_guess.text: - new_best_matches.update([nugget.text]) - new_to_old_match[nugget.text] = current_guess.text + new_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] + if previous_best_match != new_best_match: + new_best_matches.update([new_best_match.text]) + new_to_old_match[new_best_match.text] = previous_best_match.text + distances_based_on_label = False # Find more nuggets that are similar to this match @@ -498,13 +503,16 @@ def run_nugget_pipeline(nuggets): for nugget, new_distance in zip(document.nuggets, new_distances): if distances_based_on_label or new_distance < nugget[CachedDistanceSignal]: nugget[CachedDistanceSignal] = new_distance + + previous_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] for ix, nugget in enumerate(document.nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: document[CurrentMatchIndexSignal] = ix - if nugget.text != current_guess.text: - new_best_matches.update([nugget.text]) - new_to_old_match[nugget.text] = current_guess.text + new_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] + if previous_best_match != new_best_match: + new_best_matches.update([new_best_match.text]) + new_to_old_match[new_best_match.text] = previous_best_match.text distances_based_on_label = False if self._adjust_threshold: @@ -561,9 +569,6 @@ def run_nugget_pipeline(nuggets): best_guesses.append((document.name, None)) statistics[attribute.name]["best_guesses"].append((num_feedback, best_guesses)) - if self._old_max_distance == -1: - self._old_max_distance = self._max_distance - tak: float = time.time() logger.info(f"Executed interactive matching in {tak - tik} seconds.") diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index d6b2ddad..4230acb2 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -92,9 +92,11 @@ def update_list(self, threshold_updates: List[ThresholdPositionUpdate]): def _create_label_and_tooltip_text(self, update: ThresholdPositionUpdate) -> Tuple[str, str]: moving_direction = update.new_position.name.lower() - label_text = f"{update.best_guess} ({update.count})" + label_text = f"{update.best_guess} {'(' + str(update.count) + ')' if update.count > 1 else ''}" + distance_change_text = f"Old distance: {round(update.old_distance, 4)} -> New distance: {round(update.new_distance, 4)}\n" if update.old_distance is not None \ + else f"Initial distance: {round(update.new_distance, 4)}\n" tooltip_text = (f"Due to your last feedback {update.best_guess} moved {moving_direction} the threshold.\n" - f"Old distance: {round(update.old_distance, 4) if update.old_distance is not None else ''} -> New distance: {round(update.new_distance, 4)}\n" + f"{distance_change_text}" f"This happened for {update.count - 1} similar nuggets as well.") return label_text, tooltip_text diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index aa638c29..6ad39534 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -372,10 +372,14 @@ def hide_visualizations(self): self._update_stylesheets(False) def _handle_item_is_new(self, newly_added_nugget_context): + distance_change_text = (f"Old distance: {round(newly_added_nugget_context.old_distance, 4)} -> " + f"New distance: {round(newly_added_nugget_context.new_distance, 4)}") \ + if newly_added_nugget_context.old_distance is not None \ + else f"Initial distance: {round(newly_added_nugget_context.new_distance, 4)}" self._tooltip_text = ( - f'{newly_added_nugget_context.added_reason.corresponding_tooltip_text}\n' - f'Old Distance: {round(newly_added_nugget_context.old_distance, 4) if newly_added_nugget_context.old_distance is not None else ""} -> ' - f'New Distance: {round(newly_added_nugget_context.new_distance, 4)}') + f'Reason for the item to be newly added:\n' + f'{newly_added_nugget_context.added_reason.corresponding_tooltip_text}\n\n' + f'{distance_change_text}') if not self._visualizations: return diff --git a/wannadb_ui/wannadb_api.py b/wannadb_ui/wannadb_api.py index dfaaa4d6..b9370db7 100644 --- a/wannadb_ui/wannadb_api.py +++ b/wannadb_ui/wannadb_api.py @@ -21,7 +21,7 @@ from wannadb.preprocessing.label_paraphrasing import OntoNotesLabelParaphraser, \ SplitAttributeNameLabelParaphraser from wannadb.preprocessing.normalization import CopyNormalizer -from wannadb.preprocessing.other_processing import ContextSentenceCacher +from wannadb.preprocessing.other_processing import ContextSentenceCacher, DuplicatedNuggetsCleaner from wannadb.statistics import Statistics from wannadb.status import StatusCallback from wannadb_parsql.cache_db import SQLiteCacheDB @@ -129,7 +129,7 @@ def create_document_base(self, path, attribute_names, statistics): RelativePositionEmbedder(), DuplicatedNuggetsCleaner(), PCAReducer(), - TSNEReducer() + #TSNEReducer() ]) # run preprocessing phase @@ -356,7 +356,7 @@ def interactive_table_population(self, document_base, statistics): SBERTLabelEmbedder("SBERTBertLargeNliMeanTokensResource"), SBERTDocumentSentenceEmbedder("SBERTBertLargeNliMeanTokensResource"), PCAReducer(), - TSNEReducer(), + #TSNEReducer(), RankingBasedMatcher( distance=SignalsMeanDistance( signal_identifiers=[ @@ -383,7 +383,7 @@ def interactive_table_population(self, document_base, statistics): BERTContextSentenceEmbedder("BertLargeCasedResource"), RelativePositionEmbedder(), PCAReducer(), - TSNEReducer() + #TSNEReducer() ] ), find_additional_nuggets=FaissSentenceSimilarityExtractor(num_similar_sentences=20, num_phrases_per_sentence=3), From 05ce1882dae87ef2b7c5b816cee5e5f3bc85ca2f Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 17 Aug 2024 18:51:45 +0200 Subject: [PATCH 49/85] add possibility to switch between 3 levels of visualizations --- wannadb_ui/common.py | 118 +++++++++++++---------- wannadb_ui/data_insights.py | 36 ++++++- wannadb_ui/interactive_matching.py | 148 ++++++++++++++++++----------- wannadb_ui/main_window.py | 56 +++++------ 4 files changed, 221 insertions(+), 137 deletions(-) diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 102e7441..0fed4628 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -1,4 +1,4 @@ -import abc +from abc import ABC, abstractmethod from enum import Enum from typing import Union, List, Optional @@ -40,6 +40,48 @@ INPUT_DOCS_COLUMN_NAME = "input_document" +class ThresholdPosition(Enum): + ABOVE = 1 + BELOW = 2 + + +class AvailableVisualizationsLevel(Enum): + DISABLED = 0 + LEVEL_1 = 1 + LEVEL_2 = 2 + + +class NuggetUpdateType(Enum): + NEWLY_ADDED = 1 + THRESHOLD_POSITION_UPDATE = 2 + BEST_MATCH_UPDATE = 3 + + +class AddedReason(Enum): + MOST_UNCERTAIN = "The documents match belongs to the considered most uncertain matches." + INTERESTING_ADDITIONAL_EXTRACTION = "The document recently got interesting additional extraction to the list." + AT_THRESHOLD = "The distance of the guessed match is within the considered range around the threshold." + + def __init__(self, corresponding_tooltip_text: str): + self._corresponding_tooltip_text = corresponding_tooltip_text + + @property + def corresponding_tooltip_text(self): + return self._corresponding_tooltip_text + + +class VisualizationProvidingItem: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def update_shown_visualizations(self, visualization_level: AvailableVisualizationsLevel): + self._adapt_to_visualizations_level(visualization_level) + + @abstractmethod + def _adapt_to_visualizations_level(self, visualizations_level): + pass + + class MainWindowContent(QWidget): def __init__(self, main_window, header_text): @@ -65,11 +107,11 @@ def __init__(self, main_window, header_text): self.controls_widget_layout.setContentsMargins(0, 0, 0, 0) self.top_widget_layout.addWidget(self.controls_widget, alignment=Qt.AlignmentFlag.AlignRight) - @abc.abstractmethod + @abstractmethod def enable_input(self): raise NotImplementedError - @abc.abstractmethod + @abstractmethod def disable_input(self): raise NotImplementedError @@ -90,17 +132,7 @@ def __init__(self, main_window_content, sub_header_text): self.layout.addWidget(self.sub_header) -class VisualizationsProvidingItem: - @abc.abstractmethod - def show_visualizations(self): - raise NotImplementedError - - @abc.abstractmethod - def hide_visualizations(self): - raise NotImplementedError - - -class CustomScrollableList(QWidget, VisualizationsProvidingItem): +class CustomScrollableList(QWidget): def __init__(self, parent, item_type, floating_widget=None, orientation="vertical", above_widget=None): super(CustomScrollableList, self).__init__() @@ -149,7 +181,7 @@ def update_item_list(self, item_list, params=None): # make sure that there are enough item widgets while len(item_list) > len(self.item_widgets): - self.item_widgets.append(self.item_type(self.parent)) + self.item_widgets.append(self._create_new_widget()) # make sure that the correct number of item widgets is shown while len(item_list) > self.num_visible_item_widgets: @@ -179,15 +211,25 @@ def disable_input(self): for item_widget in self.item_widgets: item_widget.disable_input() - def show_visualizations(self): - for item_widget in self.item_widgets: - if isinstance(item_widget, VisualizationsProvidingItem): - item_widget.show_visualizations() + def _create_new_widget(self): + return self.item_type(self.parent) - def hide_visualizations(self): - for item_widget in self.item_widgets: - if isinstance(item_widget, VisualizationsProvidingItem): - item_widget.hide_visualizations() + +class VisualizationProvidingCustomScrollableList(CustomScrollableList, VisualizationProvidingItem): + def __init__(self, parent, item_type, visualizations_level, attach_visualization_level_observer, + floating_widget=None, orientation="vertical", above_widget=None): + super().__init__(parent, item_type, floating_widget, orientation, above_widget) + + self.visualizations_level = visualizations_level + self.attach_visualization_level_observer = attach_visualization_level_observer + + def _create_new_widget(self): + new_widget = self.item_type(self.parent, self.visualizations_level) + self.attach_visualization_level_observer(new_widget) + return new_widget + + def _adapt_to_visualizations_level(self, visualizations_level): + self.visualizations_level = visualizations_level class CustomScrollableListItem(QFrame): @@ -196,15 +238,15 @@ def __init__(self, parent): super(CustomScrollableListItem, self).__init__() self.parent = parent - @abc.abstractmethod + @abstractmethod def update_item(self, item, params=None): raise NotImplementedError - @abc.abstractmethod + @abstractmethod def enable_input(self): raise NotImplementedError - @abc.abstractmethod + @abstractmethod def disable_input(self): raise NotImplementedError @@ -236,24 +278,6 @@ def show_confirmation_dialog(parent, title_text, explanation_text, accept_text, return dialog.exec() -class AddedReason(Enum): - MOST_UNCERTAIN = "The documents match belongs to the considered most uncertain matches." - INTERESTING_ADDITIONAL_EXTRACTION = "The document recently got interesting additional extraction to the list." - AT_THRESHOLD = "The distance of the guessed match is within the considered range around the threshold." - - def __init__(self, corresponding_tooltip_text: str): - self._corresponding_tooltip_text = corresponding_tooltip_text - - @property - def corresponding_tooltip_text(self): - return self._corresponding_tooltip_text - - -class ThresholdPosition(Enum): - ABOVE = 1 - BELOW = 2 - - class BestMatchUpdate: def __init__(self, old_best_match: str, new_best_match: str, count: int): self._old_best_match: str = old_best_match @@ -337,12 +361,6 @@ def added_reason(self): return self._added_reason -class NuggetUpdateType(Enum): - NEWLY_ADDED = 1 - THRESHOLD_POSITION_UPDATE = 2 - BEST_MATCH_UPDATE = 3 - - class NuggetUpdatesContext: def __init__(self, newly_added_nugget_contexts: List[NewlyAddedNuggetContext], diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 4230acb2..5a17819c 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -2,10 +2,11 @@ import random from typing import Generic, TypeVar, List, Tuple +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSpacerItem, QSizePolicy, QPushButton from wannadb_ui.common import BestMatchUpdate, ThresholdPositionUpdate, ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ - BUTTON_FONT + BUTTON_FONT, VisualizationProvidingItem, AvailableVisualizationsLevel from wannadb_ui.visualizations import EmbeddingVisualizerWindow UPDATE_TYPE = TypeVar("UPDATE_TYPE") @@ -134,9 +135,38 @@ def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpd threshold_updates)) -class DataInsightsArea(QWidget): +class DataInsightsArea: def __init__(self): - super(DataInsightsArea, self).__init__() + self.suggestion_visualizer = EmbeddingVisualizerWindow() + + self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") + self.suggestion_visualizer_button.setContentsMargins(0, 0, 0, 0) + self.suggestion_visualizer_button.setFont(BUTTON_FONT) + self.suggestion_visualizer_button.setMaximumWidth(240) + self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) + + def _show_suggestion_visualizer(self): + self.suggestion_visualizer.setVisible(True) + + +class SimpleDataInsightsArea(QWidget, DataInsightsArea): + def __init__(self): + QWidget.__init__(self) + DataInsightsArea.__init__(self) + + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + + self.layout.addWidget(self.suggestion_visualizer_button, 0, Qt.AlignmentFlag.AlignRight) + + self.setVisible(False) + + +class ExtendedDataInsightsArea(QWidget, DataInsightsArea): + def __init__(self): + QWidget.__init__(self) + DataInsightsArea.__init__(self) self.layout = QVBoxLayout(self) self.layout.setSpacing(0) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 6ad39534..6f69c9a6 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -13,12 +13,11 @@ TSNEDimensionReducedLabelEmbeddingSignal from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, NewlyAddedNuggetContext, \ - VisualizationsProvidingItem -from wannadb_ui.data_insights import DataInsightsArea + VisualizationProvidingItem, AvailableVisualizationsLevel, VisualizationProvidingCustomScrollableList +from wannadb_ui.data_insights import DataInsightsArea, SimpleDataInsightsArea, ExtendedDataInsightsArea from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget from wannadb_ui.study import Tracker, track_button_click - logger = logging.getLogger(__name__) ICON_HIGH_CONFIDENCE = QIcon("wannadb_ui/resources/confidence_high.svg") @@ -36,8 +35,10 @@ def __init__(self, main_window): self.stop_button.setMaximumWidth(240) self.controls_widget_layout.addWidget(self.stop_button) - self.nugget_list_widget = NuggetListWidget(self) - self.document_widget = DocumentWidget(self) + self.nugget_list_widget = NuggetListWidget(self, main_window) + self.document_widget = DocumentWidget(self, main_window) + + main_window.attach_visualization_level_observer(self.nugget_list_widget) self.show_nugget_list_widget() @@ -98,11 +99,13 @@ def _stop_button_clicked(self): self.main_window.give_feedback_task({"message": "stop-interactive-matching"}) -class NuggetListWidget(QWidget, VisualizationsProvidingItem): - def __init__(self, interactive_matching_widget): +class NuggetListWidget(QWidget, VisualizationProvidingItem): + def __init__(self, interactive_matching_widget, main_window): super(NuggetListWidget, self).__init__(interactive_matching_widget) self.interactive_matching_widget = interactive_matching_widget + self.visualization_level = main_window.visualizations_level + self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(10) @@ -129,9 +132,11 @@ def __init__(self, interactive_matching_widget): self.num_nuggets_below_label.setFont(CODE_FONT_BOLD) # self.num_nuggets_below_label.setStyleSheet(f"color: {YELLOW}") - self.nugget_list = CustomScrollableList(self, NuggetListItemWidget, - floating_widget=self.num_nuggets_below_label, - above_widget=self.num_nuggets_above_label) + self.nugget_list = VisualizationProvidingCustomScrollableList(self, NuggetListItemWidget, + visualizations_level=main_window.visualizations_level, + attach_visualization_level_observer=main_window.attach_visualization_level_observer, + floating_widget=self.num_nuggets_below_label, + above_widget=self.num_nuggets_above_label) self.layout.addWidget(self.nugget_list) def update_nuggets(self, feedback_request): @@ -145,9 +150,9 @@ def update_nuggets(self, feedback_request): self.description.setText( "Please confirm or edit the cell value guesses displayed below until you are satisfied with the guessed values, at which point you may continue with the next attribute." "\nWannaDB will use your feedback to continuously update its guesses. Note that the cells with low confidence (low confidence bar, light yellow highlights) will be left empty.") - self.visualize_area.update_threshold_value_label(current_threshold, threshold_change) - self.visualize_area.update_best_match_list(nugget_updates_context.best_match_updates) - self.visualize_area.update_threshold_position_lists(nugget_updates_context.threshold_position_updates) + self.extended_visualize_area.update_threshold_value_label(current_threshold, threshold_change) + self.extended_visualize_area.update_best_match_list(nugget_updates_context.best_match_updates) + self.extended_visualize_area.update_threshold_position_lists(nugget_updates_context.threshold_position_updates) params = { "max_start_chars": max([nugget[CachedContextSentenceSignal]["start_char"] for nugget in feedback_nuggets]), @@ -157,15 +162,25 @@ def update_nuggets(self, feedback_request): "num-feedback": feedback_request["num-feedback"] } - self.visualize_area.setVisible(self.visualizations) + if self.visualization_level == AvailableVisualizationsLevel.LEVEL_1: + self.simple_visualize_area.setVisible(True) + self.extended_visualize_area.setVisible(False) + elif self.visualization_level == AvailableVisualizationsLevel.LEVEL_2: + self.extended_visualize_area.setVisible(True) + self.simple_visualize_area.setVisible(False) self.nugget_list.update_item_list(feedback_nuggets, params) if len(feedback_nuggets) > 0: - self.visualize_area.suggestion_visualizer.update_and_display_params(attribute=attribute, - nuggets=all_guessed_nugget_matches, - currently_highlighted_nugget=None, - best_guess=None, - other_best_guesses=[]) + self.extended_visualize_area.suggestion_visualizer.update_and_display_params(attribute=attribute, + nuggets=all_guessed_nugget_matches, + currently_highlighted_nugget=None, + best_guess=None, + other_best_guesses=[]) + self.simple_visualize_area.suggestion_visualizer.update_and_display_params(attribute=attribute, + nuggets=all_guessed_nugget_matches, + currently_highlighted_nugget=None, + best_guess=None, + other_best_guesses=[]) if feedback_request["num-nuggets-above"] > 0: self.num_nuggets_above_label.setText( @@ -192,28 +207,29 @@ def disable_accessible_color_palette(self): def disable_input(self): self.nugget_list.disable_input() - def show_visualizations(self): - self.visualizations = True - - self.visualize_area.show() - self.nugget_list.show_visualizations() - - def hide_visualizations(self): - self.visualizations = False + def _adapt_to_visualizations_level(self, visualizations_level): + self.visualization_level = visualizations_level - self.visualize_area.hide() - self.nugget_list.hide_visualizations() + if visualizations_level == AvailableVisualizationsLevel.LEVEL_1: + self.simple_visualize_area.setVisible(True) + self.extended_visualize_area.setVisible(False) + elif visualizations_level == AvailableVisualizationsLevel.LEVEL_2: + self.extended_visualize_area.setVisible(True) + self.simple_visualize_area.setVisible(False) + elif visualizations_level == AvailableVisualizationsLevel.DISABLED: + self.extended_visualize_area.setVisible(False) + self.simple_visualize_area.setVisible(False) -class NuggetListItemWidget(CustomScrollableListItem, VisualizationsProvidingItem): - def __init__(self, nugget_list_widget): +class NuggetListItemWidget(CustomScrollableListItem, VisualizationProvidingItem): + def __init__(self, nugget_list_widget, visualizations_level): super(NuggetListItemWidget, self).__init__(nugget_list_widget) self.nugget_list_widget = nugget_list_widget self.nugget = None self.other_best_guesses = None self._default_stylesheet = "QWidget#nuggetListItemWidget { background-color: white}" self._tooltip_text = "" - self._visualizations = True + self._visualizations = visualizations_level == AvailableVisualizationsLevel.LEVEL_2 self.setFixedHeight(45) self.setObjectName("nuggetListItemWidget") @@ -318,7 +334,7 @@ def update_item(self, item, params=None): # self.info_button.setText(f"{str(round(self.nugget[CachedDistanceSignal], 2)).ljust(4)}") - @track_button_click(button_name= "nugget_list_match_button") + @track_button_click(button_name="nugget_list_match_button") def _match_button_clicked(self): self.nugget_list_widget.interactive_matching_widget.main_window.give_feedback_task({ "message": "is-match", @@ -354,7 +370,13 @@ def disable_input(self): self.match_button.setDisabled(True) self.fix_button.setDisabled(True) - def show_visualizations(self): + def _adapt_to_visualizations_level(self, visualizations_level): + if visualizations_level == AvailableVisualizationsLevel.LEVEL_2: + self._show_visualizations() + else: + self._hide_visualizations() + + def _show_visualizations(self): self._visualizations = True if self._tooltip_text != "": @@ -363,7 +385,7 @@ def show_visualizations(self): f"Low confidence in this match {self._build_distance_text()}, will not be included in result.") self._update_stylesheets(item_is_new=self._tooltip_text != "") - def hide_visualizations(self): + def _hide_visualizations(self): self._visualizations = False self.setToolTip("") @@ -373,9 +395,9 @@ def hide_visualizations(self): def _handle_item_is_new(self, newly_added_nugget_context): distance_change_text = (f"Old distance: {round(newly_added_nugget_context.old_distance, 4)} -> " - f"New distance: {round(newly_added_nugget_context.new_distance, 4)}") \ - if newly_added_nugget_context.old_distance is not None \ - else f"Initial distance: {round(newly_added_nugget_context.new_distance, 4)}" + f"New distance: {round(newly_added_nugget_context.new_distance, 4)}") \ + if newly_added_nugget_context.old_distance is not None \ + else f"Initial distance: {round(newly_added_nugget_context.new_distance, 4)}" self._tooltip_text = ( f'Reason for the item to be newly added:\n' f'{newly_added_nugget_context.added_reason.corresponding_tooltip_text}\n\n' @@ -407,9 +429,9 @@ def _update_stylesheets(self, item_is_new): self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") -class DocumentWidget(QWidget, VisualizationsProvidingItem): - def __init__(self, interactive_matching_widget): - super(DocumentWidget, self).__init__(interactive_matching_widget) +class DocumentWidget(QWidget, VisualizationProvidingItem): + def __init__(self, interactive_matching_widget, main_window): + super(DocumentWidget, self).__init__(parent=interactive_matching_widget) self.interactive_matching_widget = interactive_matching_widget self.layout = QVBoxLayout() @@ -427,6 +449,8 @@ def __init__(self, interactive_matching_widget): self.nuggets_in_order = [] self.nuggets_sorted_by_distance = [] + main_window.attach_visualization_level_observer(self) + self.description = QLabel( "Please select the correct value by clicking on one of the highlighted snippets. You may also " "highlight a different span of text in case the required value is not highlighted already.") @@ -452,8 +476,12 @@ def __init__(self, interactive_matching_widget): self.custom_selection_item_widget = CustomSelectionItemWidget(self) self.custom_selection_item_widget.hide() - self.suggestion_list = CustomScrollableList(self, SuggestionListItemWidget, orientation="horizontal", - above_widget=self.custom_selection_item_widget) + self.suggestion_list = VisualizationProvidingCustomScrollableList(self, SuggestionListItemWidget, + main_window.visualizations_level, + main_window.attach_visualization_level_observer, + orientation="horizontal", + above_widget=self.custom_selection_item_widget) + self.suggestion_list.setFixedHeight(60) self.layout.addWidget(self.suggestion_list) @@ -487,7 +515,7 @@ def __init__(self, interactive_matching_widget): self.match_button.clicked.connect(self._match_button_clicked) self.buttons_widget_layout.addWidget(self.match_button) - @track_button_click(button_name= "document_match_button") + @track_button_click(button_name="document_match_button") def _match_button_clicked(self): if self.current_nugget is None: logger.info("Confirm custom nugget!") @@ -505,7 +533,7 @@ def _match_button_clicked(self): "not-a-match": None if self.current_nugget is self.original_nugget else self.original_nugget }) - @track_button_click(button_name= "document_no_match_button") + @track_button_click(button_name="document_no_match_button") def _no_match_button_clicked(self): self.interactive_matching_widget.main_window.give_feedback_task({ "message": "no-match-in-document", @@ -691,12 +719,10 @@ def disable_accessible_color_palette(self): def show_visualizations(self): self.upper_buttons_widget.show() self.visualizer.show() - self.suggestion_list.show_visualizations() - def hide_visualizations(self): + def _hide_visualizations(self): self.upper_buttons_widget.hide() self.visualizer.hide() - self.suggestion_list.hide_visualizations() def _highlight_best_guess(self, best_guess): if best_guess is None: @@ -704,13 +730,21 @@ def _highlight_best_guess(self, best_guess): self.visualizer.highlight_best_guess(best_guess) + def _adapt_to_visualizations_level(self, visualizations_level): + if (visualizations_level == AvailableVisualizationsLevel.LEVEL_2 or + visualizations_level == AvailableVisualizationsLevel.LEVEL_1): + self._show_visualizations() + elif visualizations_level == AvailableVisualizationsLevel.DISABLED: + self._hide_visualizations() -class SuggestionListItemWidget(CustomScrollableListItem, VisualizationsProvidingItem): - def __init__(self, suggestion_list_widget): +class SuggestionListItemWidget(CustomScrollableListItem, VisualizationProvidingItem): + + def __init__(self, suggestion_list_widget, visualizations_level): super(SuggestionListItemWidget, self).__init__(suggestion_list_widget) self.suggestion_list_widget = suggestion_list_widget self.nugget = None + self.visualizations = visualizations_level == AvailableVisualizationsLevel.LEVEL_2 self.setFixedHeight(45) self.setStyleSheet(f"background-color: {WHITE}") @@ -725,6 +759,8 @@ def __init__(self, suggestion_list_widget): self.distance_label = QLabel() self.distance_label.setFont(CODE_FONT) self.layout.addWidget(self.distance_label), 0, 1 + if not self.visualizations: + self.distance_label.hide() def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: self.suggestion_list_widget.interactive_matching_widget.document_widget.current_nugget = self.nugget @@ -746,18 +782,18 @@ def update_item(self, item, params=None): else: self.setStyleSheet(f"background-color: {LIGHT_YELLOW}") - def show_visualizations(self): - self.distance_label.show() - - def hide_visualizations(self): - self.distance_label.hide() - def enable_input(self): pass def disable_input(self): pass + def _adapt_to_visualizations_level(self, visualizations_level): + if visualizations_level != AvailableVisualizationsLevel.LEVEL_2: + self.distance_label.hide() + else: + self.distance_label.show() + class CustomSelectionItemWidget(QWidget): diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index 6da94e84..d3eae00c 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -10,7 +10,8 @@ from wannadb.data.data import DocumentBase from wannadb.statistics import Statistics from wannadb_parsql.cache_db import SQLiteCacheDB -from wannadb_ui.common import MENU_FONT, STATUS_BAR_FONT, STATUS_BAR_FONT_BOLD, RED, BLACK, show_confirmation_dialog +from wannadb_ui.common import MENU_FONT, STATUS_BAR_FONT, STATUS_BAR_FONT_BOLD, RED, BLACK, show_confirmation_dialog, \ + AvailableVisualizationsLevel from wannadb_ui.document_base import DocumentBaseCreatorWidget, DocumentBaseViewerWidget, DocumentBaseCreatingWidget from wannadb_ui.interactive_matching import InteractiveMatchingWidget from wannadb_ui.start_menu import StartMenuWidget @@ -238,23 +239,15 @@ def save_statistics_to_json_task(self): # noinspection PyUnresolvedReferences self.save_statistics_to_json.emit(path, self.statistics) - def enable_visualizations_task(self): + def update_visualizations_level(self, visualizations_level): logger.info("Execute task 'enable_visualizations_task'.") - self.visualizations = True + self.visualizations_level = visualizations_level - self.interactive_matching_widget.enable_visualizations() - self.enable_visualizations_action.setEnabled(False) - self.disable_visualizations_action.setEnabled(True) + self._set_available_visualization_actions() - def disable_visualizations_task(self): - logger.info("Execute task 'disable_visualizations_task'.") - - self.visualizations = False - - self.interactive_matching_widget.disable_visualizations() - self.enable_visualizations_action.setEnabled(True) - self.disable_visualizations_action.setEnabled(False) + for observer in self.visualizations_level_observers: + observer.update_shown_visualizations(visualizations_level) def enable_accessible_color_palette_task(self): logger.info("Execute task 'enable_accessible_color_palette_task'.") @@ -314,7 +307,7 @@ def give_feedback_task(self, feedback): self.api.feedback = feedback self.feedback_cond.wakeAll() - self._enable_visualization_settings() + self._set_available_visualization_actions() self._enable_color_palette_settings() def interactive_table_population_task(self): logger.info("Execute task 'interactive_table_population_task'.") @@ -370,7 +363,7 @@ def to_start_state(self): else: self.enable_collect_statistics_action.setEnabled(True) - self._enable_visualization_settings() + self._set_available_visualization_actions() self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.document_base_viewer_widget.hide() @@ -403,7 +396,7 @@ def to_create_document_base_state(self): else: self.enable_collect_statistics_action.setEnabled(True) - self._enable_visualization_settings() + self._set_available_visualization_actions() self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() @@ -425,7 +418,7 @@ def to_creating_document_base_state(self): self.disable_global_input() - self._enable_visualization_settings() + self._set_available_visualization_actions() self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() @@ -470,7 +463,7 @@ def to_view_document_base_state(self): else: self.enable_collect_statistics_action.setEnabled(True) - self._enable_visualization_settings() + self._set_available_visualization_actions() self._enable_color_palette_settings() self.document_base_viewer_widget.enable_input() @@ -501,7 +494,7 @@ def to_interactive_matching_state(self): else: self.enable_collect_statistics_action.setEnabled(True) - self._enable_visualization_settings() + self._set_available_visualization_actions() self._enable_color_palette_settings() self.central_widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.start_menu_widget.hide() @@ -534,7 +527,8 @@ def __init__(self) -> None: self.document_base = None self.statistics = None self.collect_statistics = True - self.visualizations = True + self.visualizations_level_observers = list() + self.visualizations_level = AvailableVisualizationsLevel.LEVEL_2 self.accessible_color_palette = False self.attributes_to_match = None self.cache_db = None @@ -664,14 +658,19 @@ def __init__(self) -> None: self.save_statistics_to_json_action.triggered.connect(self.save_statistics_to_json_task) self._all_actions.append(self.save_statistics_to_json_action) - self.enable_visualizations_action = QAction("&Enable visualizations", self) - self.enable_visualizations_action.setStatusTip("Enable visualization widgets.") - self.enable_visualizations_action.triggered.connect(self.enable_visualizations_task) - self._all_actions.append(self.enable_visualizations_action) + self.enable_lvl1_visualizations_action = QAction("&Level 1", self) + self.enable_lvl1_visualizations_action.setStatusTip("Only grid related visualizations are available.") + self.enable_lvl1_visualizations_action.triggered.connect(lambda: self.update_visualizations_level(AvailableVisualizationsLevel.LEVEL_1)) + self._all_actions.append(self.enable_lvl1_visualizations_action) + + self.enable_lvl2_visualizations_action = QAction("&Level 2", self) + self.enable_lvl2_visualizations_action.setStatusTip("All visualizations are available.") + self.enable_lvl2_visualizations_action.triggered.connect(lambda: self.update_visualizations_level(AvailableVisualizationsLevel.LEVEL_2)) + self._all_actions.append(self.enable_lvl2_visualizations_action) - self.disable_visualizations_action = QAction("&Disable visualizations", self) + self.disable_visualizations_action = QAction("&Disable", self) self.disable_visualizations_action.setStatusTip("Disable visualization widgets.") - self.disable_visualizations_action.triggered.connect(self.disable_visualizations_task) + self.disable_visualizations_action.triggered.connect(lambda: self.update_visualizations_level(AvailableVisualizationsLevel.DISABLED)) self._all_actions.append(self.disable_visualizations_action) self.enable_accessible_color_palette_action = QAction("&Enable accessible palette", self) @@ -721,10 +720,11 @@ def __init__(self) -> None: self.visualizations_menu = self.settings_menu.addMenu("&Visualizations") self.visualizations_menu.setFont(MENU_FONT) - self.visualizations_menu.addAction(self.enable_visualizations_action) self.visualizations_menu.addAction(self.disable_visualizations_action) self.visualizations_menu.addAction(self.enable_accessible_color_palette_action) self.visualizations_menu.addAction(self.disable_accessible_color_palette_action) + self.visualizations_menu.addAction(self.enable_lvl1_visualizations_action) + self.visualizations_menu.addAction(self.enable_lvl2_visualizations_action) # main UI self.central_widget = QWidget(self) From 290708b772fd4c81a94c6478b3b5c828ec4b64cd Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 24 Aug 2024 13:18:13 +0200 Subject: [PATCH 50/85] Track Show Suggestions in 3D butoon --- wannadb_ui/data_insights.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 5a17819c..5a93b4a3 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -7,6 +7,7 @@ from wannadb_ui.common import BestMatchUpdate, ThresholdPositionUpdate, ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ BUTTON_FONT, VisualizationProvidingItem, AvailableVisualizationsLevel +from wannadb_ui.study import track_button_click from wannadb_ui.visualizations import EmbeddingVisualizerWindow UPDATE_TYPE = TypeVar("UPDATE_TYPE") @@ -145,6 +146,7 @@ def __init__(self): self.suggestion_visualizer_button.setMaximumWidth(240) self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) + @track_button_click("Show Suggestions In 3D-Grid") def _show_suggestion_visualizer(self): self.suggestion_visualizer.setVisible(True) From aa39d1ec6d56ec41eeb180d7f3c92a1baa5180d6 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 24 Aug 2024 14:14:10 +0200 Subject: [PATCH 51/85] track tooltips in logs/user_report.txt --- wannadb_ui/interactive_matching.py | 31 +++++++++++++++--------------- wannadb_ui/study.py | 21 ++++++++++---------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 6f69c9a6..db37f939 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -4,7 +4,7 @@ import numpy as np from PyQt6 import QtGui -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QEvent from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy @@ -240,21 +240,12 @@ def __init__(self, nugget_list_widget, visualizations_level): self.layout.setSpacing(10) self.confidence_button = QPushButton() + self.confidence_button.event = lambda e: self.handle_tooltip_event(self.confidence_button, e) self.confidence_button.setFlat(True) self.confidence_button.setIcon(ICON_LOW_CONFIDENCE) self.confidence_button.setToolTip("Confidence in this match.") self.layout.addWidget(self.confidence_button) - # self.info_button = QPushButton() - # self.info_button.setFlat(True) - # self.info_button.setFont(CODE_FONT_BOLD) - # self.info_button.clicked.connect(self._info_button_clicked) - # self.layout.addWidget(self.info_button) - - # self.left_split_label = QLabel("|") - # self.left_split_label.setFont(CODE_FONT_BOLD) - # self.layout.addWidget(self.left_split_label) - self.text_edit = QTextEdit() self.text_edit.setReadOnly(True) self.text_edit.setFrameStyle(0) @@ -267,22 +258,22 @@ def __init__(self, nugget_list_widget, visualizations_level): self.text_edit.setText("") self.layout.addWidget(self.text_edit) - # self.right_split_label = QLabel("|") - # self.right_split_label.setFont(CODE_FONT_BOLD) - # self.layout.addWidget(self.right_split_label) - self.match_button = QPushButton() + self.match_button.event = lambda e: self.handle_tooltip_event(self.match_button, e) self.match_button.setIcon(QIcon("wannadb_ui/resources/correct.svg")) self.match_button.setToolTip("Confirm this value.") self.match_button.clicked.connect(self._match_button_clicked) self.layout.addWidget(self.match_button) self.fix_button = QPushButton() + self.fix_button.event = lambda e: self.handle_tooltip_event(self.fix_button, e) self.fix_button.setIcon(QIcon("wannadb_ui/resources/pencil.svg")) self.fix_button.setToolTip("Edit this value.") self.fix_button.clicked.connect(self._fix_button_clicked) self.layout.addWidget(self.fix_button) + self.last_tooltip_text_passed = None + def update_item(self, item, params=None): self.nugget = item @@ -428,6 +419,16 @@ def _update_stylesheets(self, item_is_new): self.setStyleSheet(self._default_stylesheet) self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") + def event(self, e): + return self.handle_tooltip_event(self, e) + + def handle_tooltip_event(self, widget, e): + if e.type() == QEvent.Type.ToolTip: + tooltip_text = widget.toolTip() # Extract the tooltip text + if self.last_tooltip_text_passed != tooltip_text: + Tracker().track_tooltip_activation(tooltip_text) + self.last_tooltip_text_passed = tooltip_text + return super(widget.__class__, widget).event(e) class DocumentWidget(QWidget, VisualizationProvidingItem): def __init__(self, interactive_matching_widget, main_window): diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index 4dc75441..1ad9c2e0 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -1,6 +1,7 @@ import logging import os import time +from collections import defaultdict from functools import wraps from PyQt6.QtCore import QObject, QTimer, QDateTime, pyqtSignal @@ -25,8 +26,8 @@ def __init__(self): super().__init__() # Call the QObject initializer self.window_open_times = {} self.timer = QTimer() - #self.timer.timeout.connect(self.calculate_time_spent) - self.button_click_counts: Dict[str, int] = {} + self.button_click_counts = defaultdict(int) + self.tooltips_hovered_counts = defaultdict(int) self.total_window_open_times = {} self._initialized = True self.log = '' @@ -50,8 +51,6 @@ def dump_report(self): tack: float = time.time() logger.info(f"Writing the report in {round(tick - tack, 2)} seconds") - #todo 1 timer works for many windows (not just for one) - #todo 2 timer works for many windows simultaneously def start_timer(self, window_name: str): self.window_open_times[window_name] = QDateTime.currentDateTime() self.timer.start(1000) @@ -78,13 +77,16 @@ def calculate_time_spent(self, window_name: str): self.sequence_number += 1 def track_button_click(self, button_name: str): - if button_name in self.button_click_counts: - self.button_click_counts[button_name] += 1 - else: - self.button_click_counts[button_name] = 1 + self.button_click_counts[button_name] += 1 self.log += f'{self.sequence_number}. {button_name} was clicked.\n' self.sequence_number += 1 + def track_tooltip_activation(self, tooltip_information: str): + tooltip_information_key = tooltip_information[:15] + self.tooltips_hovered_counts[tooltip_information_key] += 1 + self.log += f'{self.sequence_number}. The following tooltip was activated:\n {tooltip_information} \n' + self.sequence_number += 1 + def get_button_click_count(self, button_name: str) -> int: return self.button_click_counts.get(button_name, 0) @@ -99,5 +101,4 @@ def wrapper(self, *args, **kwargs): return func(self, *args, **kwargs) return wrapper - - return decorator + return decorator \ No newline at end of file From c3f30c516ebaaefc68c222081d7bedfae9281482 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 24 Aug 2024 14:32:06 +0200 Subject: [PATCH 52/85] add json file for jupyter processing --- wannadb_ui/study.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index 1ad9c2e0..6a6431c2 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -1,3 +1,4 @@ +import json import logging import os import time @@ -32,13 +33,14 @@ def __init__(self): self._initialized = True self.log = '' self.sequence_number = 1 + self.json_data = [] def dump_report(self): - tick: float = time.time() - logger.info(f"Writing the reports in the log file") log_directory = './logs' log_file = os.path.join(log_directory, 'user_report.txt') os.makedirs(log_directory, exist_ok=True) + + tick: float = time.time() with open(log_file, 'w') as file: file.write(self.log) file.write("\nTotal Statistics:\n") @@ -49,12 +51,20 @@ def dump_report(self): for window_name, time_open_in_sec in self.total_window_open_times.items(): file.write(f"\t{window_name} was open for a total of {time_open_in_sec} seconds\n") tack: float = time.time() - logger.info(f"Writing the report in {round(tick - tack, 2)} seconds") + logger.info(f"Wrote the report in {round(tick - tack, 2)} seconds") + + tick = time.time() + json_string = json.dumps(self.json_data, indent=4) + with open(os.path.join(log_directory, 'json_report.txt'), 'w') as file: + file.write(json_string) + tack = time.time() + logger.info(f"Dumped the json report file in {round(tick - tack, 2)} seconds") def start_timer(self, window_name: str): self.window_open_times[window_name] = QDateTime.currentDateTime() self.timer.start(1000) self.log += f"{self.sequence_number}. {window_name} was opened\n" + self.json_data.append({'type': 'window', 'action': 'open' ,'identifier': window_name}) self.sequence_number += 1 def stop_timer(self, window_name: str): @@ -63,7 +73,6 @@ def stop_timer(self, window_name: str): self.calculate_time_spent(window_name) def calculate_time_spent(self, window_name: str): - print(f"calculate_time_spent called with {window_name}") if self.window_open_times[window_name]: current_time = QDateTime.currentDateTime() time_spent = self.window_open_times[window_name].msecsTo(current_time) / 1000.0 # Convert to seconds @@ -75,20 +84,20 @@ def calculate_time_spent(self, window_name: str): self.total_window_open_times[window_name] = time_spent self.log += f'{self.sequence_number}. {window_name} was closed. Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' self.sequence_number += 1 + self.json_data.append({'type': 'window', 'action': 'close', 'identifier': window_name, 'time_open': time_spent}) def track_button_click(self, button_name: str): self.button_click_counts[button_name] += 1 self.log += f'{self.sequence_number}. {button_name} was clicked.\n' self.sequence_number += 1 + self.json_data.append({'type': 'button', 'identifier': button_name}) def track_tooltip_activation(self, tooltip_information: str): tooltip_information_key = tooltip_information[:15] self.tooltips_hovered_counts[tooltip_information_key] += 1 self.log += f'{self.sequence_number}. The following tooltip was activated:\n {tooltip_information} \n' self.sequence_number += 1 - - def get_button_click_count(self, button_name: str) -> int: - return self.button_click_counts.get(button_name, 0) + self.json_data.append({'type': 'tooltip', 'identifier': tooltip_information_key}) def track_button_click(button_name: str): From 38bf3e294e78a08b2c180002b43503935c407889 Mon Sep 17 00:00:00 2001 From: eneapane Date: Sat, 24 Aug 2024 14:50:54 +0200 Subject: [PATCH 53/85] more relevant information on which tooltip was activated, no information on the content of the tooltip logged --- wannadb_ui/interactive_matching.py | 12 ++++++------ wannadb_ui/study.py | 9 ++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index db37f939..c6246f65 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -240,7 +240,7 @@ def __init__(self, nugget_list_widget, visualizations_level): self.layout.setSpacing(10) self.confidence_button = QPushButton() - self.confidence_button.event = lambda e: self.handle_tooltip_event(self.confidence_button, e) + self.confidence_button.event = lambda e: self.handle_tooltip_event(self.confidence_button, e, "NuggetListItemWidget.confidence_button") self.confidence_button.setFlat(True) self.confidence_button.setIcon(ICON_LOW_CONFIDENCE) self.confidence_button.setToolTip("Confidence in this match.") @@ -259,14 +259,14 @@ def __init__(self, nugget_list_widget, visualizations_level): self.layout.addWidget(self.text_edit) self.match_button = QPushButton() - self.match_button.event = lambda e: self.handle_tooltip_event(self.match_button, e) + self.match_button.event = lambda e: self.handle_tooltip_event(self.match_button, e, "NuggetListItemWidget.match_button") self.match_button.setIcon(QIcon("wannadb_ui/resources/correct.svg")) self.match_button.setToolTip("Confirm this value.") self.match_button.clicked.connect(self._match_button_clicked) self.layout.addWidget(self.match_button) self.fix_button = QPushButton() - self.fix_button.event = lambda e: self.handle_tooltip_event(self.fix_button, e) + self.fix_button.event = lambda e: self.handle_tooltip_event(self.fix_button, e, "NuggetListItemWidget.fix_button") self.fix_button.setIcon(QIcon("wannadb_ui/resources/pencil.svg")) self.fix_button.setToolTip("Edit this value.") self.fix_button.clicked.connect(self._fix_button_clicked) @@ -420,13 +420,13 @@ def _update_stylesheets(self, item_is_new): self.text_edit.setStyleSheet(f"color: black; background-color: {WHITE}") def event(self, e): - return self.handle_tooltip_event(self, e) + return self.handle_tooltip_event(self, e, "NuggetListItemWidget") - def handle_tooltip_event(self, widget, e): + def handle_tooltip_event(self, widget, e, identifier_name): if e.type() == QEvent.Type.ToolTip: tooltip_text = widget.toolTip() # Extract the tooltip text if self.last_tooltip_text_passed != tooltip_text: - Tracker().track_tooltip_activation(tooltip_text) + Tracker().track_tooltip_activation(identifier_name) self.last_tooltip_text_passed = tooltip_text return super(widget.__class__, widget).event(e) diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index 6a6431c2..af26f4fc 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -92,12 +92,11 @@ def track_button_click(self, button_name: str): self.sequence_number += 1 self.json_data.append({'type': 'button', 'identifier': button_name}) - def track_tooltip_activation(self, tooltip_information: str): - tooltip_information_key = tooltip_information[:15] - self.tooltips_hovered_counts[tooltip_information_key] += 1 - self.log += f'{self.sequence_number}. The following tooltip was activated:\n {tooltip_information} \n' + def track_tooltip_activation(self, tooltip_object: str): + self.tooltips_hovered_counts[tooltip_object] += 1 + self.log += f'{self.sequence_number}. The following tooltip was activated:\n {tooltip_object} \n' self.sequence_number += 1 - self.json_data.append({'type': 'tooltip', 'identifier': tooltip_information_key}) + self.json_data.append({'type': 'tooltip', 'identifier': tooltip_object}) def track_button_click(button_name: str): From 577432f35b72708f81b171f1999edf997015ef0f Mon Sep 17 00:00:00 2001 From: Dongtaes Date: Tue, 27 Aug 2024 13:38:50 +0200 Subject: [PATCH 54/85] Bug Fix of the Accessibility Button --- wannadb_ui/data_insights.py | 21 +++++++++++++++++++-- wannadb_ui/interactive_matching.py | 8 +++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 5a93b4a3..7c4cae17 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -150,6 +150,14 @@ def __init__(self): def _show_suggestion_visualizer(self): self.suggestion_visualizer.setVisible(True) + def _enable_accessible_color_palette(self): + self.accessible_color_palette = True + self.suggestion_visualizer.enable_accessible_color_palette_() + + def _disable_accessible_color_palette(self): + self.accessible_color_palette = False + self.suggestion_visualizer.disable_accessible_color_palette_() + class SimpleDataInsightsArea(QWidget, DataInsightsArea): def __init__(self): @@ -164,6 +172,14 @@ def __init__(self): self.setVisible(False) + def enable_accessible_color_palette(self): + self.accessible_color_palette = True + self._enable_accessible_color_palette() + + def disable_accessible_color_palette(self): + self.accessible_color_palette = False + self._disable_accessible_color_palette() + class ExtendedDataInsightsArea(QWidget, DataInsightsArea): def __init__(self): @@ -236,11 +252,12 @@ def _show_suggestion_visualizer(self): def enable_accessible_color_palette(self): self.accessible_color_palette = True - self.suggestion_visualizer.enable_accessible_color_palette_() + self._enable_accessible_color_palette() def disable_accessible_color_palette(self): self.accessible_color_palette = False - self.suggestion_visualizer.disable_accessible_color_palette_() + self._disable_accessible_color_palette() + def update_threshold_value_label(self, new_threshold_value, threshold_value_change): if round(threshold_value_change, 4) != 0: diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index c6246f65..acfd357f 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -78,7 +78,7 @@ def show_document_widget(self): self.layout.addWidget(self.document_widget) self.stop_button.hide() - def enable_accessible_color_palette(self): + def enable_accessible_color_palette(self): self.document_widget.enable_accessible_color_palette() self.nugget_list_widget.enable_accessible_color_palette() @@ -198,11 +198,13 @@ def enable_input(self): def enable_accessible_color_palette(self): self.accessible_color_palette = True - self.visualize_area.enable_accessible_color_palette() + self.simple_visualize_area.enable_accessible_color_palette() + self.extended_visualize_area.enable_accessible_color_palette() def disable_accessible_color_palette(self): self.accessible_color_palette = False - self.visualize_area.disable_accessible_color_palette() + self.simple_visualize_area.disable_accessible_color_palette() + self.extended_visualize_area.disable_accessible_color_palette() def disable_input(self): self.nugget_list.disable_input() From 149569f68f81a0f08d8b23b305d95f39ee847d70 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 9 Sep 2024 01:47:07 +0200 Subject: [PATCH 55/85] add simple legend for 3D grid --- wannadb_ui/common.py | 22 ++++- wannadb_ui/data_insights.py | 9 +- wannadb_ui/interactive_matching.py | 2 +- wannadb_ui/visualizations.py | 129 +++++++++++++++++++++++++---- 4 files changed, 139 insertions(+), 23 deletions(-) diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 0fed4628..a03ec038 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod from enum import Enum -from typing import Union, List, Optional +from typing import Union, List, Optional, Tuple -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QFont +import pyqtgraph +from PyQt6.QtCore import Qt, QPoint +from PyQt6.QtGui import QFont, QPixmap, QPainter, QColor from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea, QFrame, QHBoxLayout, QDialog, QPushButton from wannadb.data.data import InformationNugget @@ -21,6 +22,7 @@ STATUS_BAR_FONT = QFont("Segoe UI", pointSize=11) STATUS_BAR_FONT_BOLD = QFont("Segoe UI", pointSize=11, weight=QFont.Weight.Bold) BUTTON_FONT = QFont("Segoe UI", pointSize=11) +BUTTON_FONT_SMALL = QFont("Segoe UI", pointSize=9) # colors WHITE = "#FFFFFF" @@ -381,3 +383,17 @@ def best_match_updates(self) -> List[BestMatchUpdate]: @property def threshold_position_updates(self) -> List[ThresholdPositionUpdate]: return self._threshold_position_updates + + +class AccessibleColor: + def __init__(self, color: QColor, corresponding_accessible_color: QColor): + self._color = color + self._corresponding_accessible_color = corresponding_accessible_color + + @property + def color(self): + return self._color + + @property + def corresponding_accessible_color(self): + return self._corresponding_accessible_color diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 7c4cae17..b10ef569 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -5,8 +5,9 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSpacerItem, QSizePolicy, QPushButton +from wannadb_ui import visualizations from wannadb_ui.common import BestMatchUpdate, ThresholdPositionUpdate, ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ - BUTTON_FONT, VisualizationProvidingItem, AvailableVisualizationsLevel + BUTTON_FONT, AccessibleColor from wannadb_ui.study import track_button_click from wannadb_ui.visualizations import EmbeddingVisualizerWindow @@ -138,7 +139,11 @@ def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpd class DataInsightsArea: def __init__(self): - self.suggestion_visualizer = EmbeddingVisualizerWindow() + self.suggestion_visualizer = EmbeddingVisualizerWindow([ + (AccessibleColor(visualizations.WHITE, visualizations.WHITE), 'Below threshold'), + (AccessibleColor(visualizations.RED, visualizations.ACC_RED), 'Above threshold'), + (AccessibleColor(visualizations.GREEN, visualizations.ACC_GREEN), 'Confirmed match') + ]) self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") self.suggestion_visualizer_button.setContentsMargins(0, 0, 0, 0) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index acfd357f..a9756deb 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -500,7 +500,7 @@ def __init__(self, interactive_matching_widget, main_window): self.upper_buttons_widget_layout.addWidget(self.scatter_plot_widget) self.visualizer = EmbeddingVisualizerWidget() - self.visualizer.setFixedHeight(300) + self.visualizer.setFixedHeight(355) self.layout.addWidget(self.visualizer) self.buttons_widget = QWidget() diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 7d69f6c8..f5dd7c58 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -5,10 +5,10 @@ import numpy as np import pyqtgraph as pg import pyqtgraph.opengl as gl -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QFont +from PyQt6.QtCore import Qt, QPoint +from PyQt6.QtGui import QFont, QColor, QPixmap, QPainter from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QHBoxLayout, QFrame, QScrollArea, \ - QApplication + QApplication, QLabel from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar @@ -21,6 +21,7 @@ from wannadb.data.data import InformationNugget, Attribute from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ CachedDistanceSignal, CurrentThresholdSignal +from wannadb_ui.common import AccessibleColor, BUTTON_FONT_SMALL from wannadb_ui.study import Tracker, track_button_click logger: logging.Logger = logging.getLogger(__name__) @@ -60,8 +61,68 @@ def create_sanitized_text(nugget): return nugget.text.replace("\n", " ") +class PointLegend(QLabel): + def __init__(self, point_meaning: str, point_color: QColor): + super().__init__() + + self._height = 30 + self._width = 300 + self._circle_diameter = 10 + + self._point_meaning = point_meaning + self._point_color = point_color + + self._pixmap = QPixmap(self._width, self._height) + self._pixmap.fill(Qt.GlobalColor.transparent) + + self._painter = QPainter(self._pixmap) + + circle_center = QPoint(self._circle_diameter, round(self._height / 2)) + + self._painter.setPen(Qt.PenStyle.NoPen) + self._painter.setBrush(point_color) + self._painter.drawEllipse(circle_center, self._circle_diameter, self._circle_diameter) + self._painter.setFont(BUTTON_FONT_SMALL) + self._painter.setPen(pg.mkColor('black')) + text_height = self._painter.fontMetrics().height() + self._painter.drawText(circle_center.x() + self._circle_diameter + 5, + circle_center.y() + round(text_height / 4), + f': {self._point_meaning}') + + self._painter.end() + + self.setPixmap(self._pixmap) + + +class EmbeddingVisualizerLegend(QWidget): + def __init__(self): + super().__init__() + + self.layout = QHBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + self.setLayout(self.layout) + + self._point_legends = [] + + def reset(self): + for widget in self._point_legends: + self.layout.removeWidget(widget) + self._point_legends = [] + + def update_colors_and_meanings(self, colors_with_meanings: List[Tuple[QColor, str]]): + self.reset() + + for color, meaning in colors_with_meanings: + point_legend = PointLegend(meaning, color) + self.layout.addWidget(point_legend) + self._point_legends.append(point_legend) + + class EmbeddingVisualizer: def __init__(self, + legend: EmbeddingVisualizerLegend, + colors_with_meanings: List[Tuple[AccessibleColor, str]], attribute: Attribute = None, nuggets: List[InformationNugget] = None, currently_highlighted_nugget: InformationNugget = None, @@ -75,13 +136,18 @@ def __init__(self, self._other_best_guesses: List[InformationNugget] = other_best_guesses self._nugget_to_displayed_items: Dict[InformationNugget, Tuple[GLScatterPlotItem, GLTextItem]] = dict() self._gl_widget = GLViewWidget() - self.accessible_color_palette = accessible_color_palette + self._accessible_color_palette = accessible_color_palette + self._legend = legend + self._colors_with_meanings = colors_with_meanings + self._update_legend() def enable_accessible_color_palette_(self): - self.accessible_color_palette = True + self._accessible_color_palette = True + self._update_legend() def disable_accessible_color_palette_(self): - self.accessible_color_palette = False + self._accessible_color_palette = False + self._update_legend() def update_and_display_params(self, attribute: Attribute, @@ -141,7 +207,7 @@ def highlight_best_guess(self, best_guess: InformationNugget): self._best_guess = best_guess if self._best_guess == self._currently_highlighted_nugget: - self._highlight_nugget(self._best_guess, ACC_BLUE if self.accessible_color_palette else BLUE, 15) + self._highlight_nugget(self._best_guess, ACC_BLUE if self._accessible_color_palette else BLUE, 15) return self._highlight_nugget(self._best_guess, WHITE, 15) @@ -176,7 +242,7 @@ def display_nugget_embeddings(self, nuggets): annotation_text=build_nuggets_annotation_text(nugget)) def display_attribute_embedding(self, attribute): - self.add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self.accessible_color_palette else RED), + self.add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self._accessible_color_palette else RED), annotation_text=attribute.name) self._attribute = attribute # save for later use @@ -194,7 +260,7 @@ def highlight_confirmed_matches(self): for confirmed_match in self._attribute.confirmed_matches: if confirmed_match in self._nugget_to_displayed_items: - self._highlight_nugget(confirmed_match, ACC_GREEN if self.accessible_color_palette else GREEN, DEFAULT_NUGGET_SIZE) + self._highlight_nugget(confirmed_match, ACC_GREEN if self._accessible_color_palette else GREEN, DEFAULT_NUGGET_SIZE) def reset(self): for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): @@ -208,7 +274,7 @@ def reset(self): def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( (int, Color), (int, Color)): - highlight_color = ACC_BLUE if self.accessible_color_palette else BLUE + highlight_color = ACC_BLUE if self._accessible_color_palette else BLUE highlight_size = 15 if newly_selected_nugget == self._best_guess else 10 if previously_selected_nugget is None: @@ -216,7 +282,7 @@ def _determine_update_values(self, previously_selected_nugget, newly_selected_nu reset_size = DEFAULT_NUGGET_SIZE elif (previously_selected_nugget in self._attribute.confirmed_matches or similar_prev_selected_nugget in self._attribute.confirmed_matches): - reset_color = ACC_GREEN if self.accessible_color_palette else GREEN + reset_color = ACC_GREEN if self._accessible_color_palette else GREEN reset_size = DEFAULT_NUGGET_SIZE elif previously_selected_nugget == self._best_guess: reset_color = WHITE @@ -232,7 +298,7 @@ def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: CurrentThresholdSignal.identifier not in self._attribute.signals): logger.warning(f"Could not determine nuggets color from given attribute: {self._attribute}. " f"Will return purple as color highlighting nuggets with this issue.") - return ACC_PURPLE if self.accessible_color_palette else PURPLE + return ACC_PURPLE if self._accessible_color_palette else PURPLE similar_nugget = self._nugget_to_similar_nugget[nugget] if nugget in self._nugget_to_similar_nugget else None @@ -263,20 +329,38 @@ def _highlight_nugget(self, nugget_to_highlight, new_color, new_size): scatter_to_highlight.setData(color=new_color, size=new_size) def _add_other_best_guess(self, other_best_guess): - self.add_item_to_grid(nugget_to_display_context=(other_best_guess, ACC_YELLOW if self.accessible_color_palette else YELLOW), + self.add_item_to_grid(nugget_to_display_context=(other_best_guess, ACC_YELLOW if self._accessible_color_palette else YELLOW), annotation_text=build_nuggets_annotation_text(other_best_guess), size=15) + def _update_legend(self): + def map_to_correct_color(accessible_color): + return accessible_color.corresponding_accessible_color if self._accessible_color_palette \ + else accessible_color.color + + colors_with_meanings = list(map(lambda color_with_meaning: (map_to_correct_color(color_with_meaning[0]), + color_with_meaning[1]), + self._colors_with_meanings)) + self._legend.update_colors_and_meanings(colors_with_meanings) + class EmbeddingVisualizerWindow(EmbeddingVisualizer, QMainWindow): def __init__(self, + colors_with_meanings: List[Tuple[AccessibleColor, str]], attribute: Attribute = None, nuggets: List[InformationNugget] = None, currently_highlighted_nugget: InformationNugget = None, best_guess: InformationNugget = None, other_best_guesses: List[InformationNugget] = None, accessible_color_palette: bool = False): - EmbeddingVisualizer.__init__(self, attribute, nuggets, currently_highlighted_nugget, best_guess, accessible_color_palette) + EmbeddingVisualizer.__init__(self, + legend=EmbeddingVisualizerLegend(), + colors_with_meanings=colors_with_meanings, + attribute=attribute, + nuggets=nuggets, + currently_highlighted_nugget=currently_highlighted_nugget, + best_guess=best_guess, + accessible_color_palette=accessible_color_palette) QMainWindow.__init__(self) self.accessible_color_palette = accessible_color_palette @@ -288,7 +372,8 @@ def __init__(self, self.fullscreen_layout = QVBoxLayout() central_widget.setLayout(self.fullscreen_layout) - self.fullscreen_layout.addWidget(self._gl_widget) + self.fullscreen_layout.addWidget(self._gl_widget, stretch=7) + self.fullscreen_layout.addWidget(self._legend, stretch=1) self._add_grids() @@ -322,7 +407,14 @@ class EmbeddingVisualizerWidget(EmbeddingVisualizer, QWidget): tracker: Tracker = Tracker() def __init__(self): - EmbeddingVisualizer.__init__(self) + colors_with_meanings = [ + (AccessibleColor(WHITE, WHITE), 'Below threshold'), + (AccessibleColor(ACC_RED, RED), 'Above threshold'), + (AccessibleColor(ACC_BLUE, BLUE), 'Documents best match'), + (AccessibleColor(ACC_YELLOW, YELLOW), 'Other documents best matches'), + (AccessibleColor(ACC_PURPLE, PURPLE), 'Could not determine correct color') + ] + EmbeddingVisualizer.__init__(self, EmbeddingVisualizerLegend(), colors_with_meanings) QWidget.__init__(self) self.layout = QVBoxLayout() @@ -333,6 +425,8 @@ def __init__(self): self._gl_widget.setMinimumHeight(300) # Set the initial height of the grid to 200 self.layout.addWidget(self._gl_widget) + self.layout.addWidget(self._legend) + self.best_guesses_widget = QWidget() self.best_guesses_widget_layout = QHBoxLayout(self.best_guesses_widget) self.best_guesses_widget_layout.setContentsMargins(0, 0, 0, 0) @@ -358,7 +452,8 @@ def __init__(self): @track_button_click("fullscreen embedding visualizer") def _show_embedding_visualizer_window(self): if self._fullscreen_window is None: - self._fullscreen_window = EmbeddingVisualizerWindow(attribute=self._attribute, + self._fullscreen_window = EmbeddingVisualizerWindow(colors_with_meanings=self._colors_with_meanings, + attribute=self._attribute, nuggets=list(self._nugget_to_displayed_items.keys()), currently_highlighted_nugget=self._currently_highlighted_nugget, best_guess=self._best_guess) From 6d252e2618d9917048e700f24cc33ee0994e245d Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 11 Sep 2024 11:39:06 +0200 Subject: [PATCH 56/85] Add tutorial for using bar chart --- .../visualizations/cosine_similarity.png | Bin 0 -> 24430 bytes .../visualizations/screenshot_bar_chart.png | Bin 0 -> 15470 bytes .../visualizations/screenshot_grid.png | Bin 0 -> 21093 bytes wannadb_ui/visualizations.py | 190 ++++++++++++++++-- 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 wannadb_ui/resources/visualizations/cosine_similarity.png create mode 100644 wannadb_ui/resources/visualizations/screenshot_bar_chart.png create mode 100644 wannadb_ui/resources/visualizations/screenshot_grid.png diff --git a/wannadb_ui/resources/visualizations/cosine_similarity.png b/wannadb_ui/resources/visualizations/cosine_similarity.png new file mode 100644 index 0000000000000000000000000000000000000000..e503596c670146041d596093e8715e0ebdf6e8c5 GIT binary patch literal 24430 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 literal 0 HcmV?d00001 diff --git a/wannadb_ui/resources/visualizations/screenshot_grid.png b/wannadb_ui/resources/visualizations/screenshot_grid.png new file mode 100644 index 0000000000000000000000000000000000000000..902b5417d2293c0a2f837f25af28922a66ea59b0 GIT binary patch literal 21093 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 literal 0 HcmV?d00001 diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index f5dd7c58..0cf5eb62 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,5 +1,7 @@ +import itertools import logging import math +from operator import itemgetter from typing import List, Dict, Tuple, Union import numpy as np @@ -7,8 +9,9 @@ import pyqtgraph.opengl as gl from PyQt6.QtCore import Qt, QPoint from PyQt6.QtGui import QFont, QColor, QPixmap, QPainter +from PyQt6.QtSvgWidgets import QSvgWidget from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QHBoxLayout, QFrame, QScrollArea, \ - QApplication, QLabel + QApplication, QLabel, QMessageBox, QDialog from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar @@ -35,7 +38,7 @@ YELLOW = pg.mkColor('yellow') ACC_YELLOW = pg.mkColor(254, 97, 0) PURPLE = pg.mkColor('purple') -ACC_PURPLE = pg.mkColor(120,94,240) +ACC_PURPLE = pg.mkColor(120, 94, 240) EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) DEFAULT_NUGGET_SIZE = 7 @@ -144,7 +147,7 @@ def __init__(self, def enable_accessible_color_palette_(self): self._accessible_color_palette = True self._update_legend() - + def disable_accessible_color_palette_(self): self._accessible_color_palette = False self._update_legend() @@ -260,7 +263,8 @@ def highlight_confirmed_matches(self): for confirmed_match in self._attribute.confirmed_matches: if confirmed_match in self._nugget_to_displayed_items: - self._highlight_nugget(confirmed_match, ACC_GREEN if self._accessible_color_palette else GREEN, DEFAULT_NUGGET_SIZE) + self._highlight_nugget(confirmed_match, ACC_GREEN if self._accessible_color_palette else GREEN, + DEFAULT_NUGGET_SIZE) def reset(self): for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): @@ -329,9 +333,10 @@ def _highlight_nugget(self, nugget_to_highlight, new_color, new_size): scatter_to_highlight.setData(color=new_color, size=new_size) def _add_other_best_guess(self, other_best_guess): - self.add_item_to_grid(nugget_to_display_context=(other_best_guess, ACC_YELLOW if self._accessible_color_palette else YELLOW), - annotation_text=build_nuggets_annotation_text(other_best_guess), - size=15) + self.add_item_to_grid( + nugget_to_display_context=(other_best_guess, ACC_YELLOW if self._accessible_color_palette else YELLOW), + annotation_text=build_nuggets_annotation_text(other_best_guess), + size=15) def _update_legend(self): def map_to_correct_color(accessible_color): @@ -393,11 +398,11 @@ def showEvent(self, event): def _enable_accessible_color_palette(self): self.accessible_color_palette = True self.enable_accessible_color_palette_() - + def _disable_accessible_color_palette(self): self.accessible_color_palette = False self.disable_accessible_color_palette_() - + def closeEvent(self, event): Tracker().stop_timer(str(self.__class__)) event.accept() @@ -465,14 +470,14 @@ def enable_accessible_color_palette(self): pass else: self._fullscreen_window.enable_accessible_color_palette_() - + def disable_accessible_color_palette(self): self.accessible_color_palette = False if self._fullscreen_window is None: pass else: self._fullscreen_window.disable_accessible_color_palette_() - + def return_from_embedding_visualizer_window(self): self._fullscreen_window.close() self._fullscreen_window = None @@ -539,7 +544,7 @@ def __init__(self, parent=None): self.layout.addWidget(self.button) self.data = [] self.button.clicked.connect(self.show_bar_chart) - self.window : QMainWindow = None + self.window: QMainWindow = None self.current_annotation_index = None self.bar = None @@ -629,6 +634,72 @@ def plot_bar_chart(self): self.texts = texts self.distances = rounded_distances + info_list = [ + """ + Hey there!
+ Before you access the cosine-distance scale, take a moment to read the following tips. + If you are familiar with the metrics used in WANNADB or have gone through this tutorial before, + feel free to exit using the skip button. + """, + """Cosine Similarity in 2D Plane:
+ Imagine that you and a friend are standing in the middle of a field, and both of you + point in different directions. Each direction you point is like a piece of information. + The closer your two arms are to pointing in the same direction, the more similar your + thoughts or ideas are.

+ + Same direction: If you both point in exactly the same direction, it means your ideas + (or pieces of information) are exactly alike. This is like saying: + "We’re thinking the same thing!"

+ + Opposite direction: If you point in completely opposite directions, your ideas are as + different as they can be. You’re thinking about completely different things.

+ + Right angle: If your arms are at a 90-degree angle, you're pointing in different directions, + but not as different as pointing in opposite directions. You’re thinking about different things, + but there might still be a tiny bit of connection.

+ + Before skipping over to the next tip, try to reason which vector is the most similar to vector A + in the image below! + """, + """Multi Dimensionality of Vectors and Cosine Distance
+ Vectors may have more than 2 dimensions, as was the case of you and your friend on the field. The + mathematical formula guarantees a value between -1 and 1 for each pair of vectors, for any number + of dimensions.

+ + The cosine similarity is equal to 1 when the vectors point at the same direction, -1 when the vectors + point in opposite directions, and 0 when the vectors are perpendicular to each other.

+ + As cosine similarity expresses how similar two vectors are, a higher value (in the range from -1 to 1) + expresses a higher similarity. In wanna-db we use the dual concept of cosine distance. Contrary to + cosine similarity, a higher value in the cosine distance metric, means a higher degree of dissimilarity. +
cos-dist(a, b) = 1 - cos-sim(a, b)

+ + Take a look at the image below. The yellow dots are closer to a fixed vector(not shown here), whereas the scattered + red dots are further away. Think about what the varying cosine distances imply for the spatial configuration. + + """, + """Cosine-Driven Choices: Ranking Database Values: + The bar chart shows all nuggets found inside the documents, lined after each other along the x-axis. + The y axis shows the normalized cosine distance. As we mentioned, the lower the cosine distance is, + the more certain we are that the corresponding word belongs to what we are looking for: a value in the database.

+ + + QUESTION: After you explore the bar chart, ask yourself - do the answers on the left tend to be more plausible?

+ PRO TIP: Click on each bar to show the exact value, as well as the full information nugget. + """ + ] + image_list = [ + None, + 'wannadb_ui/resources/visualizations/cosine_similarity.png', # Add the path to an SVG image + 'wannadb_ui/resources/visualizations/screenshot_grid.png', # Regular PNG image + 'wannadb_ui/resources/visualizations/screenshot_bar_chart.png' + ] + + + # Create and show the custom dialog + dialog = InfoDialog(info_list, image_list) + dialog.exec() + def on_pick(self, event): if isinstance(event.artist, Rectangle): patch = event.artist @@ -679,10 +750,10 @@ def __init__(self, parent=None): self.y = None self.scatter = None self.accessible_color_palette = False - + def enable_accessible_color_palette(self): self.accessible_color_palette = True - + def disable_accessible_color_palette(self): self.accessible_color_palette = False @@ -824,4 +895,93 @@ def showWindowEvent(self, event): def closeWindowEvent(self, event): event.accept() - Tracker().stop_timer(str(self.__class__)) \ No newline at end of file + Tracker().stop_timer(str(self.__class__)) + + +class InfoDialog(QDialog): + def __init__(self, info_list, image_list): + super().__init__() + + self.info_list = info_list + self.image_list = image_list + self.current_index = 0 + + # Set up the dialog layout + self.layout = QVBoxLayout() + + # Set a fixed width for the dialog + self.setFixedWidth(400) # Set the fixed width you prefer + + # Label to display the information text + self.info_label = QLabel(self.info_list[self.current_index]) + self.info_label.setWordWrap(True) # Enable word wrap for the label + self.layout.addWidget(self.info_label) + + # Widget to display the PNG image + self.image_widget = QLabel() + self.layout.addWidget(self.image_widget) + + self.update_image() # Update image on dialog creation + + # Buttons for navigation (Previous, Next, Skip) + self.button_layout = QHBoxLayout() + + self.prev_button = QPushButton("Previous") + self.prev_button.clicked.connect(self.show_previous) + self.button_layout.addWidget(self.prev_button) + + self.next_button = QPushButton("Next") + self.next_button.clicked.connect(self.show_next) + self.button_layout.addWidget(self.next_button) + + self.skip_button = QPushButton("Skip") + self.skip_button.clicked.connect(self.skip) + self.button_layout.addWidget(self.skip_button) + + # Add button layout to the main layout + self.layout.addLayout(self.button_layout) + + # Disable the "Previous" button initially + self.update_buttons() + + # Set the layout for the dialog + self.setLayout(self.layout) + + # Method to update the displayed information + def update_info(self): + self.info_label.setText(self.info_list[self.current_index]) + self.update_image() + + # Method to update the displayed PNG image + def update_image(self): + image_path = self.image_list[self.current_index] + if image_path and image_path.endswith(".png"): + pixmap = QPixmap(image_path) + self.image_widget.setPixmap(pixmap) + self.image_widget.setVisible(True) + else: + self.image_widget.clear() + self.image_widget.setVisible(False) + + # Method to update the state of the buttons + def update_buttons(self): + self.prev_button.setEnabled(self.current_index > 0) + self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) + + # Method to show the previous piece of information + def show_previous(self): + if self.current_index > 0: + self.current_index -= 1 + self.update_info() + self.update_buttons() + + # Method to show the next piece of information + def show_next(self): + if self.current_index < len(self.info_list) - 1: + self.current_index += 1 + self.update_info() + self.update_buttons() + + # Method to skip and close the dialog + def skip(self): + self.accept() \ No newline at end of file From 5f778682586dfc79d8ae333eda01655b9066d390 Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 11 Sep 2024 14:16:05 +0200 Subject: [PATCH 57/85] Fix y-axis annotation --- wannadb_ui/visualizations.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 0cf5eb62..a7b27f9d 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -582,7 +582,7 @@ def plot_bar_chart(self): self.bar = ax.bar(texts, rounded_distances, alpha=0.75, picker=True, color=get_colors(distances)) ax.set_xticks([]) - ax.set_ylabel('Cosine Similarity', fontsize=15) + ax.set_ylabel('Cosine Distance', fontsize=15) ax.set_xlabel('Information Nuggets', fontsize=15) fig.subplots_adjust(left=0.115, right=0.920, top=0.945, bottom=0.065) for idx, rect in enumerate(self.bar): @@ -695,8 +695,6 @@ def plot_bar_chart(self): 'wannadb_ui/resources/visualizations/screenshot_bar_chart.png' ] - - # Create and show the custom dialog dialog = InfoDialog(info_list, image_list) dialog.exec() From 674c0e7c4be65860a17c31580131f8ebb628e7d4 Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 11 Sep 2024 14:21:26 +0200 Subject: [PATCH 58/85] Remove scatter plot --- wannadb_ui/interactive_matching.py | 9 +-------- wannadb_ui/visualizations.py | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index a9756deb..7c52faae 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -15,7 +15,7 @@ CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, NewlyAddedNuggetContext, \ VisualizationProvidingItem, AvailableVisualizationsLevel, VisualizationProvidingCustomScrollableList from wannadb_ui.data_insights import DataInsightsArea, SimpleDataInsightsArea, ExtendedDataInsightsArea -from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget, ScatterPlotVisualizerWidget +from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget from wannadb_ui.study import Tracker, track_button_click logger = logging.getLogger(__name__) @@ -495,9 +495,6 @@ def __init__(self, interactive_matching_widget, main_window): self.cosine_barchart = BarChartVisualizerWidget() self.cosine_barchart.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.upper_buttons_widget_layout.addWidget(self.cosine_barchart) - self.scatter_plot_widget = ScatterPlotVisualizerWidget() - self.scatter_plot_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.upper_buttons_widget_layout.addWidget(self.scatter_plot_widget) self.visualizer = EmbeddingVisualizerWidget() self.visualizer.setFixedHeight(355) @@ -680,7 +677,6 @@ def update_document(self, nugget, other_best_guesses): best_guess=self.nuggets_sorted_by_distance[0], other_best_guesses=other_best_guesses) self.cosine_barchart.update_data(self.nuggets_sorted_by_distance) - self.scatter_plot_widget.update_data(self.nuggets_sorted_by_distance) else: self.idx_mapper = {} @@ -697,9 +693,6 @@ def update_document(self, nugget, other_best_guesses): self.text_edit.setTextCursor(scroll_cursor) self.text_edit.ensureCursorVisible() - def clear_scatter_plot_data(self): - self.scatter_plot_widget.clear_data() - def enable_input(self): self.match_button.setEnabled(True) self.no_match_button.setEnabled(True) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index a7b27f9d..748662f7 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -982,4 +982,4 @@ def show_next(self): # Method to skip and close the dialog def skip(self): - self.accept() \ No newline at end of file + self.accept() From 45334c4faa9c79ac71c6d8f0d8d321e37863bf57 Mon Sep 17 00:00:00 2001 From: eneapane Date: Wed, 11 Sep 2024 14:57:16 +0200 Subject: [PATCH 59/85] Show tutorial for bar chart only once per application usage --- wannadb_ui/visualizations.py | 323 +++++++++++++++++++++++++++-------- 1 file changed, 254 insertions(+), 69 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 748662f7..3543c383 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -534,7 +534,110 @@ def _handle_remove_other_best_guesses_clicked(self): if self._fullscreen_window is not None: self._fullscreen_window.remove_nuggets_from_widget(self._other_best_guesses) +class InfoDialog(QDialog): + def __init__(self): + super().__init__() + + self.dialog_shown: bool = False + + self.info_list = None + self.image_list = None + self.current_index = 0 + + # Set up the dialog layout + self.layout = QVBoxLayout() + + # Set a fixed width for the dialog + self.setFixedWidth(400) # Set the fixed width you prefer + + # Label to display the information text + self.info_label = QLabel() + self.info_label.setWordWrap(True) # Enable word wrap for the label + self.layout.addWidget(self.info_label) + + # Widget to display the PNG image + self.image_widget = QLabel() + self.layout.addWidget(self.image_widget) + + # Buttons for navigation (Previous, Next, Skip) + self.button_layout = QHBoxLayout() + + self.prev_button = QPushButton("Previous") + self.prev_button.clicked.connect(self.show_previous) + self.button_layout.addWidget(self.prev_button) + + self.next_button = QPushButton("Next") + self.next_button.clicked.connect(self.show_next) + self.button_layout.addWidget(self.next_button) + + self.skip_button = QPushButton("Skip") + self.skip_button.clicked.connect(self.skip) + self.button_layout.addWidget(self.skip_button) + + # Add button layout to the main layout + self.layout.addLayout(self.button_layout) + + # Set the layout for the dialog + self.setLayout(self.layout) + + # Setter method to set the info_list + def set_info_list(self, info_list): + self.info_list = info_list + self.update_info() + + # Setter method to set the image_list + def set_image_list(self, image_list): + self.image_list = image_list + self.update_image() + + # Method to update the displayed information + def update_info(self): + if self.info_list is not None: + self.info_label.setText(self.info_list[self.current_index]) + self.update_image() + self.update_buttons() + + # Method to update the displayed PNG image + def update_image(self): + if self.image_list is not None: + image_path = self.image_list[self.current_index] + if image_path and image_path.endswith(".png"): + pixmap = QPixmap(image_path) + self.image_widget.setPixmap(pixmap) + self.image_widget.setVisible(True) + else: + self.image_widget.clear() + self.image_widget.setVisible(False) + + # Method to update the state of the buttons + def update_buttons(self): + if self.info_list is not None: + self.prev_button.setEnabled(self.current_index > 0) + self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) + + # Method to show the previous piece of information + def show_previous(self): + if self.current_index > 0: + self.current_index -= 1 + self.update_info() + + # Method to show the next piece of information + def show_next(self): + if self.current_index < len(self.info_list) - 1: + self.current_index += 1 + self.update_info() + # Method to skip and close the dialog + def skip(self): + self.accept() + + # Override exec to prevent multiple executions + def exec(self): + if not self.dialog_shown: + super().exec() + self.dialog_shown = True + +dialog = InfoDialog() class BarChartVisualizerWidget(QWidget): def __init__(self, parent=None): super(BarChartVisualizerWidget, self).__init__(parent) @@ -695,7 +798,10 @@ def plot_bar_chart(self): 'wannadb_ui/resources/visualizations/screenshot_bar_chart.png' ] - dialog = InfoDialog(info_list, image_list) + global dialog + assert len(info_list) == len(image_list) + dialog.set_info_list(info_list) + dialog.set_image_list(image_list) dialog.exec() def on_pick(self, event): @@ -896,90 +1002,169 @@ def closeWindowEvent(self, event): Tracker().stop_timer(str(self.__class__)) -class InfoDialog(QDialog): - def __init__(self, info_list, image_list): - super().__init__() +class ScatterPlotVisualizerWidget(QWidget): + def __init__(self, parent=None): + super(ScatterPlotVisualizerWidget, self).__init__(parent) + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.button = QPushButton("Show Scatter Plot with Cosine Distances") + self.layout.addWidget(self.button) + self.data = [] # Store data as a list of tuples + self.button.clicked.connect(self.show_scatter_plot) + self.scatter_plot_canvas = None + self.scatter_plot_toolbar = None + self.window = None + self.annotation = None + self.texts = None + self.distances = None + self.y = None + self.scatter = None + self.accessible_color_palette = False - self.info_list = info_list - self.image_list = image_list - self.current_index = 0 + def enable_accessible_color_palette(self): + self.accessible_color_palette = True - # Set up the dialog layout - self.layout = QVBoxLayout() + def disable_accessible_color_palette(self): + self.accessible_color_palette = False - # Set a fixed width for the dialog - self.setFixedWidth(400) # Set the fixed width you prefer + def update_data(self, nuggets): + self.reset() - # Label to display the information text - self.info_label = QLabel(self.info_list[self.current_index]) - self.info_label.setWordWrap(True) # Enable word wrap for the label - self.layout.addWidget(self.info_label) + self.data = [(create_sanitized_text(nugget), + np.round(nugget[CachedDistanceSignal], 3)) + for nugget in nuggets] - # Widget to display the PNG image - self.image_widget = QLabel() - self.layout.addWidget(self.image_widget) + def reset(self): + self.data = [] + self.texts = None + self.distances = None + self.y = None + self.scatter = None + if self.window is not None: + self.window.close() + self.scatter_plot_canvas = None + self.scatter_plot_toolbar = None + self.window = None + self.annotation = None - self.update_image() # Update image on dialog creation + @track_button_click("show scatter plot") + def show_scatter_plot(self): + if not self.data: + return - # Buttons for navigation (Previous, Next, Skip) - self.button_layout = QHBoxLayout() + # Clear data to prevent duplication + self.data = list(set(self.data)) - self.prev_button = QPushButton("Previous") - self.prev_button.clicked.connect(self.show_previous) - self.button_layout.addWidget(self.prev_button) + # Close existing scatter plot + if self.window is not None: + self.window.close() - self.next_button = QPushButton("Next") - self.next_button.clicked.connect(self.show_next) - self.button_layout.addWidget(self.next_button) + fig = Figure() + ax = fig.add_subplot(111) + texts, distances = zip(*self.data) - self.skip_button = QPushButton("Skip") - self.skip_button.clicked.connect(self.skip) - self.button_layout.addWidget(self.skip_button) + # Round the distances to a fixed number of decimal places + rounded_distances = np.round(distances, 3) - # Add button layout to the main layout - self.layout.addLayout(self.button_layout) + # Ensure consistent x-values for the same rounded distance + distance_map = {} + for original, rounded in zip(distances, rounded_distances): + if rounded not in distance_map: + distance_map[rounded] = original - # Disable the "Previous" button initially - self.update_buttons() + consistent_distances = [distance_map[rd] for rd in rounded_distances] - # Set the layout for the dialog - self.setLayout(self.layout) + # Generate jittered y-values for points with the same x-value + unique_distances = {} + for i, distance in enumerate(consistent_distances): + if distance not in unique_distances: + unique_distances[distance] = [] + unique_distances[distance].append(i) - # Method to update the displayed information - def update_info(self): - self.info_label.setText(self.info_list[self.current_index]) - self.update_image() + y = np.zeros(len(distances)) + for distance, indices in unique_distances.items(): + jitter = np.linspace(-0.4, 0.4, len(indices)) + for j, index in enumerate(indices): + y[index] = jitter[j] - # Method to update the displayed PNG image - def update_image(self): - image_path = self.image_list[self.current_index] - if image_path and image_path.endswith(".png"): - pixmap = QPixmap(image_path) - self.image_widget.setPixmap(pixmap) - self.image_widget.setVisible(True) - else: - self.image_widget.clear() - self.image_widget.setVisible(False) + # Generating a list of colors for each point + num_points = len(distances) + colormap = plt.cm.jet + norm = plt.Normalize(min(rounded_distances), max(rounded_distances)) + colors = colormap(norm(rounded_distances)) - # Method to update the state of the buttons - def update_buttons(self): - self.prev_button.setEnabled(self.current_index > 0) - self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) + # Plot the points + scatter = ax.scatter(rounded_distances, y, c=colors, alpha=0.75, picker=True) # Enable picking - # Method to show the previous piece of information - def show_previous(self): - if self.current_index > 0: - self.current_index -= 1 - self.update_info() - self.update_buttons() + ax.set_xlabel("Cosine Distance") + ax.set_xlim(min(rounded_distances) - 0.05, + max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility + ax.set_yticks([]) # Remove y-axis labels to avoid confusion + fig.subplots_adjust(left=0.020, right=0.980, top=0.940, bottom=0.075) + # fig.tight_layout() + + # Create canvas + self.scatter_plot_canvas = FigureCanvas(fig) + + # Create a new window for the plot + self.window = QMainWindow() + self.window.closeEvent = self.closeWindowEvent + self.window.showEvent = self.showWindowEvent + self.window.setWindowTitle("Scatter Plot") + + self.window.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) + + # Set the central widget of the window to the canvas + self.window.setCentralWidget(self.scatter_plot_canvas) + + # Add NavigationToolbar to the window + self.scatter_plot_toolbar = NavigationToolbar(self.scatter_plot_canvas, self.window) + self.window.addToolBar(self.scatter_plot_toolbar) + + # Show the window + self.window.show() + self.scatter_plot_canvas.draw() + + # Create an annotation box + self.annotation = ax.annotate( + "", xy=(0, 0), xytext=(20, 20), + textcoords="offset points", bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->") + ) + self.annotation.set_visible(False) + + # Connect the pick event + self.scatter_plot_canvas.mpl_connect("pick_event", self.on_pick) + + # Store the data for use in the event handler + self.texts = texts + self.distances = rounded_distances + self.y = y + self.scatter = scatter + + def on_pick(self, event): + if event.artist != self.scatter: + return + # Get index of the picked point + ind = event.ind[0] + + # Update annotation text and position + self.annotation.xy = (self.distances[ind], self.y[ind]) + text = f"Text: {self.texts[ind]}\nValue: {self.distances[ind]:.3f}" + self.annotation.set_text(text) + self.annotation.set_visible(True) + self.scatter_plot_canvas.draw_idle() + + def reset(self): + self.data = [] + self.bar = None + + def showWindowEvent(self, event): + super().showEvent(event) + Tracker().start_timer(str(self.__class__)) + + def closeWindowEvent(self, event): + event.accept() + Tracker().stop_timer(str(self.__class__)) - # Method to show the next piece of information - def show_next(self): - if self.current_index < len(self.info_list) - 1: - self.current_index += 1 - self.update_info() - self.update_buttons() - # Method to skip and close the dialog - def skip(self): - self.accept() From e92fbca5b11f93ee9c98808dce2a490e710ea62f Mon Sep 17 00:00:00 2001 From: eneapane Date: Fri, 13 Sep 2024 17:01:57 +0200 Subject: [PATCH 60/85] Fix bar chart with suggestions --- wannadb_ui/visualizations.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 3543c383..373ed28e 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -49,7 +49,7 @@ WINDOW_HEIGHT = int(screen_geometry.height() * 0.7) -def get_colors(distances, color_start='red', color_end='blue'): +def get_colors(distances, color_start='green', color_end='red'): cmap = LinearSegmentedColormap.from_list("CustomMap", [color_start, color_end]) norm = plt.Normalize(min(distances), max(distances)) colors = [cmap(norm(value)) for value in distances] @@ -681,11 +681,19 @@ def plot_bar_chart(self): ax = fig.add_subplot(111) texts, distances = zip(*self.data) - rounded_distances = np.round(distances, 3) + rounded_distances = np.round(np.ones(len(distances)) - distances, 3) + x_positions = [0] + for i, y_val in enumerate(rounded_distances): + if i == 0: + continue + if rounded_distances[i - 1] != y_val: + x_positions.append(x_positions[i - 1] + 2) + else: + x_positions.append(x_positions[i - 1] + 1) - self.bar = ax.bar(texts, rounded_distances, alpha=0.75, picker=True, color=get_colors(distances)) + self.bar = ax.bar(x_positions, rounded_distances, alpha=0.75, picker=True, color=get_colors(distances)) ax.set_xticks([]) - ax.set_ylabel('Cosine Distance', fontsize=15) + ax.set_ylabel('Certainty', fontsize=15) ax.set_xlabel('Information Nuggets', fontsize=15) fig.subplots_adjust(left=0.115, right=0.920, top=0.945, bottom=0.065) for idx, rect in enumerate(self.bar): From 4617a55a4263b6c339940c83443127bc7136a4c7 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 14 Sep 2024 10:39:05 +0200 Subject: [PATCH 61/85] implement information popups, splash screen and corresponding help menu --- wannadb_ui/common.py | 22 +- wannadb_ui/main_window.py | 54 + wannadb_ui/resources/popups/header_image.svg | 1031 +++++++++++++++++ .../popups/ideas_and_architecture_info.md | 30 + wannadb_ui/resources/popups/splash.png | Bin 0 -> 58115 bytes wannadb_ui/resources/popups/splash_screen.md | 16 + wannadb_ui/resources/popups/usage_info.md | 19 + .../resources/popups/visualization_info.md | 12 + 8 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 wannadb_ui/resources/popups/header_image.svg create mode 100644 wannadb_ui/resources/popups/ideas_and_architecture_info.md create mode 100644 wannadb_ui/resources/popups/splash.png create mode 100644 wannadb_ui/resources/popups/splash_screen.md create mode 100644 wannadb_ui/resources/popups/usage_info.md create mode 100644 wannadb_ui/resources/popups/visualization_info.md diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index a03ec038..749e69a8 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -2,10 +2,12 @@ from enum import Enum from typing import Union, List, Optional, Tuple +import markdown import pyqtgraph from PyQt6.QtCore import Qt, QPoint from PyQt6.QtGui import QFont, QPixmap, QPainter, QColor -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea, QFrame, QHBoxLayout, QDialog, QPushButton +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea, QFrame, QHBoxLayout, QDialog, QPushButton, \ + QMainWindow, QTextEdit from wannadb.data.data import InformationNugget @@ -280,6 +282,24 @@ def show_confirmation_dialog(parent, title_text, explanation_text, accept_text, return dialog.exec() +class InformationPopup(QMainWindow): + def __init__(self, title: str, content_file_to_display: str): + super().__init__() + + self._text_widget = QTextEdit() + + with open(content_file_to_display, "r") as file: + formatted_text = file.read() + markdown_result = markdown.markdown(formatted_text) + + self._text_widget.setHtml(markdown_result) + + self.setCentralWidget(self._text_widget) + + self.setWindowTitle(title) + self.resize(1000, 700) + + class BestMatchUpdate: def __init__(self, old_best_match: str, new_best_match: str, count: int): self._old_best_match: str = old_best_match diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index d3eae00c..37741285 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -15,6 +15,7 @@ from wannadb_ui.document_base import DocumentBaseCreatorWidget, DocumentBaseViewerWidget, DocumentBaseCreatingWidget from wannadb_ui.interactive_matching import InteractiveMatchingWidget from wannadb_ui.start_menu import StartMenuWidget +from wannadb_ui.common import InformationPopup from wannadb_ui.wannadb_api import WannaDBAPI logger = logging.getLogger(__name__) @@ -333,6 +334,25 @@ def match_attribute_task(self, attribute_name): # noinspection PyUnresolvedReferences self.interactive_table_population.emit(self.document_base, self.statistics) + def open_usage_info_task(self): + logger.info("Execute task 'open_usage_info_task'.") + + if self.usage_info_popup.isHidden(): + self.usage_info_popup.show() + + def open_visualization_info_task(self): + logger.info("Execute task 'open_visualization_info_task'.") + + if self.visualization_info_popup.isHidden(): + self.visualization_info_popup.show() + + def open_general_info_task(self): + logger.info("Execute task 'open_general_info_task'.") + + if self.general_info_popup.isHidden(): + self.general_info_popup.show() + + ################## # controller logic ################## @@ -532,6 +552,9 @@ def __init__(self) -> None: self.accessible_color_palette = False self.attributes_to_match = None self.cache_db = None + self.usage_info_popup = InformationPopup("Usage Information", "wannadb_ui/resources/popups/usage_info.md") + self.visualization_info_popup = InformationPopup("Visualization Information", "wannadb_ui/resources/popups/visualization_info.md") + self.general_info_popup = InformationPopup("Underlying Ideas / Architecture", "wannadb_ui/resources/popups/ideas_and_architecture_info.md") # set up the api_thread and api and connect slots and signals self.feedback_mutex = QMutex() @@ -683,6 +706,23 @@ def __init__(self) -> None: self.disable_accessible_color_palette_action.triggered.connect(self.disable_accessible_color_palette_task) self._all_actions.append(self.disable_accessible_color_palette_action) + self.open_usage_info = QAction("&Open usage info", self) + self.open_usage_info.setStatusTip("Open popup providing some information about the usage of the application.") + self.open_usage_info.triggered.connect(self.open_usage_info_task) + + self.open_visualization_info = QAction("&Open visualization info", self) + self.open_visualization_info.setStatusTip("Open popup providing some information about the available visualizations.") + self.open_visualization_info.triggered.connect(self.open_visualization_info_task) + + self.open_general_info = QAction("&Open general info", self) + self.open_general_info.setStatusTip("Open popup providing some general information about the application.") + self.open_general_info.triggered.connect(self.open_general_info_task) + + self.open_usage_info = QAction("&Open usage info", self) + self.open_usage_info.setStatusTip("Open usage popup providing some usage information.") + self.open_usage_info.triggered.connect(self.open_usage_info_task) + + # set up the menu bar self.menubar = self.menuBar() self.menubar.setFont(MENU_FONT) @@ -726,6 +766,16 @@ def __init__(self) -> None: self.visualizations_menu.addAction(self.enable_lvl1_visualizations_action) self.visualizations_menu.addAction(self.enable_lvl2_visualizations_action) + self.help_menu = self.menubar.addMenu("&Help") + self.help_menu.setFont(MENU_FONT) + + self.general_menu = self.help_menu.addMenu("&General") + self.general_menu.addAction(self.open_general_info) + self.usage_menu = self.help_menu.addMenu("&Usage") + self.usage_menu.addAction(self.open_usage_info) + self.visualization_menu = self.help_menu.addMenu("&Visualization") + self.visualization_menu.addAction(self.open_visualization_info) + # main UI self.central_widget = QWidget(self) self.central_widget_layout = QHBoxLayout(self.central_widget) @@ -743,4 +793,8 @@ def __init__(self) -> None: self.resize(1400, 800) self.show() + # Information popup + self.information_popup = InformationPopup("Quick Start Guide", "wannadb_ui/resources/popups/splash_screen.md") + self.information_popup.show() + logger.info("Initialized MainWindow.") diff --git a/wannadb_ui/resources/popups/header_image.svg b/wannadb_ui/resources/popups/header_image.svg new file mode 100644 index 00000000..2d0308e7 --- /dev/null +++ b/wannadb_ui/resources/popups/header_image.svg @@ -0,0 +1,1031 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Report + AAR + - + 10 + - + 05 + On + August + 8 + , + 2009 + , + at + 1153 + : + 14 + eastern + daylight + time, + a + Piper + PA + - + 32 + R + - + 300 + airplane, + N + 71 + MC, + and + a + Eurocopter + AS + 350 + BA + helicopter, + N + 401 + LH, + operated + by + Liberty + Helicopters, + collided + over + the + Hudson + River + near + Hoboken, + New + Jersey + . + The + pilot + and + two + passengers + aboard + the + airplane + and + the + pilot + and + five + passengers + aboard + the + helicopter + were + killed, + and + both + aircraft + received + substantial + damage + from + the + impact + . + The + airplane + flight + was + operating + under + the + provisions + of + 14 + Code + of + Federal + Regulations + (CFR) + Part + 91 + , + and + the + helicopter + flight + was + operating + under + the + provisions + of + 14 + CFR + Parts + 135 + and + 136 + . + No + flight + plans + were + filed + or + were + required + for + either + flight, + and + visual + meteorological + conditions + prevailed + at + the + time + of + the + accident + . + + + + + + + + + + + + + + Report + AAR + - + 07 + - + 03 + On + September + 24 + , + 2004 + , + about + 1642 + Hawaiian + standard + time, + a + Bell + 206 + B + helicopter, + N + 16849 + , + registered + to + and + operated + by + Bali + Hai + Helicopter + Tours, + Inc + . + , + of + Hanapepe, + Hawaii, + impacted + mountainous + terrain + in + Kalaheo, + Hawaii, + on + the + island + of + Kauai, + 8 + . + 4 + miles + northeast + of + Port + Allen + Airport, + in + Hanapepe + . + The + commercial + pilot + and + the + four + passengers + were + killed, + and + the + helicopter + was + destroyed + by + impact + forces + and + postimpact + fire + . + The + nonstop + sightseeing + air + tour + flight + was + operated + under + the + provisions + of + 14 + Code + of + Federal + Regulations + Part + 91 + and + visual + flight + rules + with + no + flight + plan + filed + . + Instrument + meteorological + conditions + prevailed + near + the + accident + site + . + The + safety + issues + discussed + in + this + report + include + the + + + + + + + + + + + + + + + Report + AAB + - + 12 + - + 01 + ​On + September + 16 + , + 2011 + , + about + 1625 + Pacific + daylight + time, + an + experimental, + single + - + seat + North + American + P + - + 51 + D, + N + 79111 + , + collided + with + the + airport + ramp + in + the + spectator + box + seating + area + following + a + loss + of + control + during + the + National + Championship + Air + Races + unlimited + class + gold + race + at + the + Reno/Stead + Airport + (RST), + Reno, + Nevada + . + The + airplane + was + registered + to + Aero + - + Trans + Corp + (dba + Leeward + Aeronautical + Sales), + Ocala, + Florida, + and + operated + by + the + commercial + pilot + as + Race + 177 + , + The + Galloping + Ghost, + under + the + provisions + of + 14 + Code + of + Federal + Regulations + Part + 91 + . + The + pilot + and + 10 + people + on + the + ground + sustained + fatal + injuries, + and + at + least + 64 + people + on + the + ground + were + injured + (at + least + 16 + of + whom + were + reported + to + have + sustained + serious + injuries) + . + The + airplane + sustained + substantial + damage, + fragmenting + upon + collision + with + + + + + + + + + + + + + + + Report + AAR + - + 00 + - + 01 + On + August + 6 + , + 1997 + , + about + 0142 + : + 26 + Guam + local + time, + Korean + Air + flight + 801 + , + a + Boeing + 747 + - + 3 + B + 5 + B + ( + 747 + - + 300 + ), + Korean + registration + HL + 7468 + , + operated + by + Korean + Air + Company, + Ltd + . + , + crashed + at + Nimitz + Hill, + Guam + . + Flight + 801 + departed + from + Kimpo + International + Airport, + Seoul, + Korea, + with + 2 + pilots, + 1 + flight + engineer, + 14 + flight + attendants, + and + 237 + passengers + on + board + . + The + airplane + had + been + cleared + to + land + on + runway + 6 + Left + at + A + . + B + . + Won + Guam + International + Airport, + Agana, + Guam, + and + crashed + into + high + terrain + about + 3 + miles + southwest + of + the + airport + . + Of + the + 254 + persons + on + board, + 228 + were + killed, + and + 23 + passengers + and + 3 + flight + attendants + survived + the + accident + with + serious + injuries + . + The + airplane + was + destroyed + by + impact + forces + and + a + postcrash + fire + . + Flight + 801 + was + operating + in + U + . + S + . + airspace + as + a + regularly + scheduled + international + passenger + service + + + + + + + + + + + + + + + + + + + + + Report + AAB + - + 06 + - + 07 + On + November + 27 + , + 2004 + , + about + 0820 + Afghanistan + time, + a + Construcciones + Aeronauticas + Sociedad + Anonima + C + - + 212 + - + CC + (CASA + 212 + ) + twin + - + engine + turboprop + airplane, + N + 960 + BW + , + registered + to + Aviation + Worldwide + Services, + LLC, + and + operated + by + Presidential + + Airways, + + Inc + . + , + of + Melbourne, + Florida, + collided + with + mountainous + terrain + in + the + vicinity + of + the + Bamiyan + Valley, + near + Bamiyan, + Afghanistan + . + The + Department + of + Defense + (DoD) + contract + flight + was + operated + under + the + provisions + of + 14 + Code + of + Federal + Regulations + (CFR) + Part + 135 + , + with + a + company + flight + plan + filed + . + Daylight + visual + meteorological + conditions + (VMC) + prevailed + . + The + captain, + the + first + officer, + and + the + mechanic + - + certificated + passenger, + who + were + U + . + S + . + civilians + employed + by + the + operator, + and + the + three + military + passengers, + who + were + active + - + duty + U + . + S + . + Army + soldiers + ... + + + + registration_number + airline + date + N47BA + Sunjet + Aviation Inc. + 1999 + - + 10 + - + 25 + N411WL + American Airlines + 1998 + - + 02 + - + 09 + N960BW + + + Presidential Airways Inc. + + 2004 + - + 11 + - + 27 + N314AB + Air Sunshine, Inc. + 2003 + - + 07 + - + 13 + N14053 + - + 2001 + - + 11 + - + 12 + FedEx + 2003 + - + 12 + - + 18 + N438AT + Executive Airlines + 2004 + - + 05 + - + 09 + + + \ No newline at end of file diff --git a/wannadb_ui/resources/popups/ideas_and_architecture_info.md b/wannadb_ui/resources/popups/ideas_and_architecture_info.md new file mode 100644 index 00000000..2f7a6aca --- /dev/null +++ b/wannadb_ui/resources/popups/ideas_and_architecture_info.md @@ -0,0 +1,30 @@ +# WannaDB: Ad-hoc SQL Queries over Text Collections + +![Document collection and corresponding table.](header_image.svg) + +WannaDB allows users to explore unstructured text collections by automatically organizing the relevant information nuggets in a table. It supports ad-hoc SQL queries over text collections using a novel two-phased approach: First, a superset of information nuggets is extracted from the texts using existing extractors such as named entity recognizers. The extractions are then interactively matched to a structured table definition as requested by the user. + +Watch our [demo video](https://link.tuda.systems/aset-video) or [read our paper](https://doi.org/10.18420/BTW2023-08) to learn more about the usage and underlying concepts. + + +# Underlying architecture and ideas + +This section gives a brief insight into the ideas facilitating the possibilities provided by WannaDB. + +## Cosine distance + +The cosine distance is used to measure the similarity between text snippets (nuggets) or text snippets and attributes. It's calculated as the cosine distance between the embedding vectors of two nuggets or a nugget and an attribute. +In order to decide whether a nugget matches an attribute, the system calculates the cosine distance between the nugget and either the attribute to match or the closest nugget identified as a confirmed match for this attribute. The initial distance of a nugget is always its distance to the corresponding attribute. During the feedback process, the feedback process keeps track of the confirmed matches for this attribute and might update the nuggets distance to the distance to a confirmed match if this lowers the nuggets distance. The shorter the computed distance, the greater the confidence that this nugget matches this attribute. +This distance is calculated for each extracted nugget within a document and the nugget with the lowest distance is considered as the best match of this document. +As the user gives feedback the cosine distance of a nugget might change as this feedback might result in a close confirmed match. + +## Threshold + +The threshold refers to the maximum cosine distance from which a nugget isn't be considered as a match for an attribute and therefore not added to the resulting table. The best match of a document is only added to the table if its distance is below the current threshold. +During the user's feedback process, the threshold might change its value multiple times to utilize the user's feedback as much as possible. + +## Interactive matching process + +The interactive matching process is the workflow in which the cells of the table are populated based on feedback given by the user. +In order to populate cells related to some attribute, the user gives feedback to the corresponding nugget matches determined by the system. The feedback provided by the user (confirms/rejects a match) for a specific document influences the computed distance of other nuggets and the threshold as there might be new confirmed matches. Therefore, each feedback round might lead to changing best matches in other documents. +In this way, the system kind of learns from the user's feedback and tries to improve the resulting table with each feedback round without requiring user feedback for each document. diff --git a/wannadb_ui/resources/popups/splash.png b/wannadb_ui/resources/popups/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..336e52943599c974835883bc6185d835f5d1e865 GIT binary patch literal 58115 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

  • GU}j&>%%hONsklb z{nNdXpn&cc6lAOHwWohyHE*djtt;WpKK((It>rsE_Qq)m5Wf*d$(Z@!ana-$Tarf> zpDvi^Ry#ATs|fA9GlbiA+^uni8;>T0+l^P2`|01*WA@8|TrDO?AUOrBA?YD3jN1ci6FdOC`I9KIY z$b<7-^j6N?2uPLx1MldYBG?=+SNh{ojhC~L_7>Ws$@V#>Y&5;eUe~stT`{X?Qwd+J zM3^WWNv{(cn0joE_2|!OAQ|(K$#+!n7#=jXNWRLJ?0~>O>Uxyuq5Z?K(+czm%olZq z@q&T(yo0$IQv)D7!9dyYPOk{&_)k}(P2gbozLV|QEqrbcBPps{L_LnHQ# zVlsPb1C=$+lfH&J( *Export table to CSV*. +Furthermore, the resulting table can be saved for later use and modification via *Document base* -> *Save document base*. \ No newline at end of file diff --git a/wannadb_ui/resources/popups/visualization_info.md b/wannadb_ui/resources/popups/visualization_info.md new file mode 100644 index 00000000..6a4aa46e --- /dev/null +++ b/wannadb_ui/resources/popups/visualization_info.md @@ -0,0 +1,12 @@ +# Visualizations + +## 3D-Grid +The 3D-Grid aims to visualize how text snippets are interpreted by the system. +Each text snippet extracted from one of the provided documents is represented by a vector of numbers within the system. This vector contains information about the meaning of the text snippet and is called *embedding vector* or just *embedding*. +These embedding vectors are displayed in the 3D-Grid which makes it possible to recognize that words with similar meaning are also mapped to similar embedding vectors. +Furthermore, the grid shows where a text snippet lies relative to the current threshold. The threshold basically determines whether a found text snippet should be considered as a match or not. To learn more about it, check [this section](#threshold). + +## Cosine-Distance Bar-Chart +This bar-chart attempts to display the system-calculated confidence with which a text snippet from a document matches an attribute. +In order to determine this certainty, the system uses the similarity between the embedding of a text snippet and the embedding of either the attribute to match or an already confirmed match for this attribute. What exactly is used the comparative value (attribute or already confirmed match) depends on which value results in a higher similarity score. +To learn more about how this similarity is calculated, check the *Underlying architecture and ideas* help section. \ No newline at end of file From cadb1d6a637744bc0e4603b4daa28c7761aca761 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 14 Sep 2024 10:42:37 +0200 Subject: [PATCH 62/85] adjust resource folder --- wannadb_ui/main_window.py | 8 ++++---- .../cosine_similarity.png | Bin .../{popups => info_popups}/header_image.svg | 0 .../ideas_and_architecture_info.md | 0 .../screenshot_bar_chart.png | Bin .../screenshot_grid.png | Bin .../resources/{popups => info_popups}/splash.png | Bin .../{popups => info_popups}/splash_screen.md | 0 .../{popups => info_popups}/usage_info.md | 0 .../visualization_info.md | 0 wannadb_ui/visualizations.py | 15 +++++++++------ 11 files changed, 13 insertions(+), 10 deletions(-) rename wannadb_ui/resources/{visualizations => info_popups}/cosine_similarity.png (100%) rename wannadb_ui/resources/{popups => info_popups}/header_image.svg (100%) rename wannadb_ui/resources/{popups => info_popups}/ideas_and_architecture_info.md (100%) rename wannadb_ui/resources/{visualizations => info_popups}/screenshot_bar_chart.png (100%) rename wannadb_ui/resources/{visualizations => info_popups}/screenshot_grid.png (100%) rename wannadb_ui/resources/{popups => info_popups}/splash.png (100%) rename wannadb_ui/resources/{popups => info_popups}/splash_screen.md (100%) rename wannadb_ui/resources/{popups => info_popups}/usage_info.md (100%) rename wannadb_ui/resources/{popups => info_popups}/visualization_info.md (100%) diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index 37741285..ad4705ad 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -552,9 +552,9 @@ def __init__(self) -> None: self.accessible_color_palette = False self.attributes_to_match = None self.cache_db = None - self.usage_info_popup = InformationPopup("Usage Information", "wannadb_ui/resources/popups/usage_info.md") - self.visualization_info_popup = InformationPopup("Visualization Information", "wannadb_ui/resources/popups/visualization_info.md") - self.general_info_popup = InformationPopup("Underlying Ideas / Architecture", "wannadb_ui/resources/popups/ideas_and_architecture_info.md") + self.usage_info_popup = InformationPopup("Usage Information", "wannadb_ui/resources/info_popups/usage_info.md") + self.visualization_info_popup = InformationPopup("Visualization Information", "wannadb_ui/resources/info_popups/visualization_info.md") + self.general_info_popup = InformationPopup("Underlying Ideas / Architecture", "wannadb_ui/resources/info_popups/ideas_and_architecture_info.md") # set up the api_thread and api and connect slots and signals self.feedback_mutex = QMutex() @@ -794,7 +794,7 @@ def __init__(self) -> None: self.show() # Information popup - self.information_popup = InformationPopup("Quick Start Guide", "wannadb_ui/resources/popups/splash_screen.md") + self.information_popup = InformationPopup("Quick Start Guide", "wannadb_ui/resources/info_popups/splash_screen.md") self.information_popup.show() logger.info("Initialized MainWindow.") diff --git a/wannadb_ui/resources/visualizations/cosine_similarity.png b/wannadb_ui/resources/info_popups/cosine_similarity.png similarity index 100% rename from wannadb_ui/resources/visualizations/cosine_similarity.png rename to wannadb_ui/resources/info_popups/cosine_similarity.png diff --git a/wannadb_ui/resources/popups/header_image.svg b/wannadb_ui/resources/info_popups/header_image.svg similarity index 100% rename from wannadb_ui/resources/popups/header_image.svg rename to wannadb_ui/resources/info_popups/header_image.svg diff --git a/wannadb_ui/resources/popups/ideas_and_architecture_info.md b/wannadb_ui/resources/info_popups/ideas_and_architecture_info.md similarity index 100% rename from wannadb_ui/resources/popups/ideas_and_architecture_info.md rename to wannadb_ui/resources/info_popups/ideas_and_architecture_info.md diff --git a/wannadb_ui/resources/visualizations/screenshot_bar_chart.png b/wannadb_ui/resources/info_popups/screenshot_bar_chart.png similarity index 100% rename from wannadb_ui/resources/visualizations/screenshot_bar_chart.png rename to wannadb_ui/resources/info_popups/screenshot_bar_chart.png diff --git a/wannadb_ui/resources/visualizations/screenshot_grid.png b/wannadb_ui/resources/info_popups/screenshot_grid.png similarity index 100% rename from wannadb_ui/resources/visualizations/screenshot_grid.png rename to wannadb_ui/resources/info_popups/screenshot_grid.png diff --git a/wannadb_ui/resources/popups/splash.png b/wannadb_ui/resources/info_popups/splash.png similarity index 100% rename from wannadb_ui/resources/popups/splash.png rename to wannadb_ui/resources/info_popups/splash.png diff --git a/wannadb_ui/resources/popups/splash_screen.md b/wannadb_ui/resources/info_popups/splash_screen.md similarity index 100% rename from wannadb_ui/resources/popups/splash_screen.md rename to wannadb_ui/resources/info_popups/splash_screen.md diff --git a/wannadb_ui/resources/popups/usage_info.md b/wannadb_ui/resources/info_popups/usage_info.md similarity index 100% rename from wannadb_ui/resources/popups/usage_info.md rename to wannadb_ui/resources/info_popups/usage_info.md diff --git a/wannadb_ui/resources/popups/visualization_info.md b/wannadb_ui/resources/info_popups/visualization_info.md similarity index 100% rename from wannadb_ui/resources/popups/visualization_info.md rename to wannadb_ui/resources/info_popups/visualization_info.md diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 373ed28e..4301e6f3 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -9,9 +9,8 @@ import pyqtgraph.opengl as gl from PyQt6.QtCore import Qt, QPoint from PyQt6.QtGui import QFont, QColor, QPixmap, QPainter -from PyQt6.QtSvgWidgets import QSvgWidget from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QHBoxLayout, QFrame, QScrollArea, \ - QApplication, QLabel, QMessageBox, QDialog + QApplication, QLabel, QDialog from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar @@ -534,6 +533,7 @@ def _handle_remove_other_best_guesses_clicked(self): if self._fullscreen_window is not None: self._fullscreen_window.remove_nuggets_from_widget(self._other_best_guesses) + class InfoDialog(QDialog): def __init__(self): super().__init__() @@ -637,7 +637,10 @@ def exec(self): super().exec() self.dialog_shown = True + dialog = InfoDialog() + + class BarChartVisualizerWidget(QWidget): def __init__(self, parent=None): super(BarChartVisualizerWidget, self).__init__(parent) @@ -681,7 +684,7 @@ def plot_bar_chart(self): ax = fig.add_subplot(111) texts, distances = zip(*self.data) - rounded_distances = np.round(np.ones(len(distances)) - distances, 3) + rounded_distances = np.round(np.ones(len(distances)) - distances, 3) x_positions = [0] for i, y_val in enumerate(rounded_distances): if i == 0: @@ -801,9 +804,9 @@ def plot_bar_chart(self): ] image_list = [ None, - 'wannadb_ui/resources/visualizations/cosine_similarity.png', # Add the path to an SVG image - 'wannadb_ui/resources/visualizations/screenshot_grid.png', # Regular PNG image - 'wannadb_ui/resources/visualizations/screenshot_bar_chart.png' + 'wannadb_ui/resources/info_popups/cosine_similarity.png', # Add the path to an SVG image + 'wannadb_ui/resources/info_popups/screenshot_grid.png', # Regular PNG image + 'wannadb_ui/resources/info_popups/screenshot_bar_chart.png' ] global dialog From 2fdfe43db3b76ddd9c9c203f3df8c3d85a8b343d Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 14 Sep 2024 10:51:37 +0200 Subject: [PATCH 63/85] move helper model classes to proper location --- wannadb/matching/matching.py | 4 +- wannadb/models.py | 125 +++++++++++++++++ wannadb_ui/common.py | 217 ++++++++++++++--------------- wannadb_ui/data_insights.py | 5 +- wannadb_ui/interactive_matching.py | 3 +- wannadb_ui/visualizations.py | 107 +------------- 6 files changed, 235 insertions(+), 226 deletions(-) create mode 100644 wannadb/models.py diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 5010607f..094b7a54 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -13,10 +13,10 @@ from wannadb.interaction import BaseInteractionCallback from wannadb.matching.custom_match_extraction import BaseCustomMatchExtractor from wannadb.matching.distance import BaseDistance +from wannadb.models import NewlyAddedNuggetContext, NuggetUpdatesContext, BestMatchUpdate, ThresholdPositionUpdate from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback -from wannadb_ui.common import BestMatchUpdate, AddedReason, NewlyAddedNuggetContext, NuggetUpdatesContext, \ - ThresholdPosition, ThresholdPositionUpdate +from wannadb_ui.common import AddedReason, ThresholdPosition logger: logging.Logger = logging.getLogger(__name__) diff --git a/wannadb/models.py b/wannadb/models.py new file mode 100644 index 00000000..958a7953 --- /dev/null +++ b/wannadb/models.py @@ -0,0 +1,125 @@ +from typing import Optional, Union, List + +from PyQt6.QtGui import QColor + +from wannadb.data.data import InformationNugget +from wannadb_ui.common import ThresholdPosition, AddedReason + + +class BestMatchUpdate: + def __init__(self, old_best_match: str, new_best_match: str, count: int): + self._old_best_match: str = old_best_match + self._new_best_match: str = new_best_match + self._count: int = count + + @property + def old_best_match(self) -> str: + return self._old_best_match + + @property + def new_best_match(self) -> str: + return self._new_best_match + + @property + def count(self) -> int: + return self._count + + +class ThresholdPositionUpdate: + def __init__(self, best_guess: str, + old_position: Optional[ThresholdPosition], new_position: ThresholdPosition, + old_distance: Optional[float], new_distance: float, + count: int): + self._best_guess: str = best_guess + self._old_position: Optional[ThresholdPosition] = old_position + self._new_position: ThresholdPosition = new_position + self._old_distance: float = old_distance + self._new_distance: float = new_distance + self._count: int = count + + @property + def best_guess(self) -> str: + return self._best_guess + + @property + def old_position(self) -> Optional[ThresholdPosition]: + return self._old_position + + @property + def new_position(self) -> ThresholdPosition: + return self._new_position + + @property + def old_distance(self) -> Optional[float]: + return self._old_distance + + @property + def new_distance(self) -> float: + return self._new_distance + + @property + def count(self) -> int: + return self._count + + +class NewlyAddedNuggetContext: + def __init__(self, nugget: InformationNugget, + old_distance: Union[float, None], + new_distance: float, + added_reason: AddedReason): + self._nugget = nugget + self._old_distance = old_distance + self._new_distance = new_distance + self._added_reason = added_reason + + @property + def nugget(self): + return self._nugget + + @property + def old_distance(self): + return self._old_distance + + @property + def new_distance(self): + return self._new_distance + + @property + def added_reason(self): + return self._added_reason + + +class NuggetUpdatesContext: + def __init__(self, + newly_added_nugget_contexts: List[NewlyAddedNuggetContext], + best_match_updates: List[BestMatchUpdate], + threshold_position_updates: List[ThresholdPositionUpdate]): + self._newly_added_nugget_contexts: List[NewlyAddedNuggetContext] = newly_added_nugget_contexts + self._best_match_updates: List[BestMatchUpdate] = best_match_updates + self._threshold_position_updates: List[ThresholdPositionUpdate] = threshold_position_updates + + @property + def newly_added_nugget_contexts(self) -> List[NewlyAddedNuggetContext]: + return self._newly_added_nugget_contexts + + @property + def best_match_updates(self) -> List[BestMatchUpdate]: + return self._best_match_updates + + @property + def threshold_position_updates(self) -> List[ThresholdPositionUpdate]: + return self._threshold_position_updates + + +class AccessibleColor: + def __init__(self, color: QColor, corresponding_accessible_color: QColor): + self._color = color + self._corresponding_accessible_color = corresponding_accessible_color + + @property + def color(self): + return self._color + + @property + def corresponding_accessible_color(self): + return self._corresponding_accessible_color \ No newline at end of file diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 749e69a8..c4ea8554 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -300,120 +300,105 @@ def __init__(self, title: str, content_file_to_display: str): self.resize(1000, 700) -class BestMatchUpdate: - def __init__(self, old_best_match: str, new_best_match: str, count: int): - self._old_best_match: str = old_best_match - self._new_best_match: str = new_best_match - self._count: int = count - - @property - def old_best_match(self) -> str: - return self._old_best_match - - @property - def new_best_match(self) -> str: - return self._new_best_match - - @property - def count(self) -> int: - return self._count - - -class ThresholdPositionUpdate: - def __init__(self, best_guess: str, - old_position: Optional[ThresholdPosition], new_position: ThresholdPosition, - old_distance: Optional[float], new_distance: float, - count: int): - self._best_guess: str = best_guess - self._old_position: Optional[ThresholdPosition] = old_position - self._new_position: ThresholdPosition = new_position - self._old_distance: float = old_distance - self._new_distance: float = new_distance - self._count: int = count - - @property - def best_guess(self) -> str: - return self._best_guess - - @property - def old_position(self) -> Optional[ThresholdPosition]: - return self._old_position - - @property - def new_position(self) -> ThresholdPosition: - return self._new_position - - @property - def old_distance(self) -> Optional[float]: - return self._old_distance - - @property - def new_distance(self) -> float: - return self._new_distance - - @property - def count(self) -> int: - return self._count - - -class NewlyAddedNuggetContext: - def __init__(self, nugget: InformationNugget, - old_distance: Union[float, None], - new_distance: float, - added_reason: AddedReason): - self._nugget = nugget - self._old_distance = old_distance - self._new_distance = new_distance - self._added_reason = added_reason - - @property - def nugget(self): - return self._nugget - - @property - def old_distance(self): - return self._old_distance - - @property - def new_distance(self): - return self._new_distance - - @property - def added_reason(self): - return self._added_reason - - -class NuggetUpdatesContext: - def __init__(self, - newly_added_nugget_contexts: List[NewlyAddedNuggetContext], - best_match_updates: List[BestMatchUpdate], - threshold_position_updates: List[ThresholdPositionUpdate]): - self._newly_added_nugget_contexts: List[NewlyAddedNuggetContext] = newly_added_nugget_contexts - self._best_match_updates: List[BestMatchUpdate] = best_match_updates - self._threshold_position_updates: List[ThresholdPositionUpdate] = threshold_position_updates - - @property - def newly_added_nugget_contexts(self) -> List[NewlyAddedNuggetContext]: - return self._newly_added_nugget_contexts - - @property - def best_match_updates(self) -> List[BestMatchUpdate]: - return self._best_match_updates - - @property - def threshold_position_updates(self) -> List[ThresholdPositionUpdate]: - return self._threshold_position_updates - - -class AccessibleColor: - def __init__(self, color: QColor, corresponding_accessible_color: QColor): - self._color = color - self._corresponding_accessible_color = corresponding_accessible_color - - @property - def color(self): - return self._color +class InfoDialog(QDialog): + def __init__(self): + super().__init__() - @property - def corresponding_accessible_color(self): - return self._corresponding_accessible_color + self.dialog_shown: bool = False + + self.info_list = None + self.image_list = None + self.current_index = 0 + + # Set up the dialog layout + self.layout = QVBoxLayout() + + # Set a fixed width for the dialog + self.setFixedWidth(400) # Set the fixed width you prefer + + # Label to display the information text + self.info_label = QLabel() + self.info_label.setWordWrap(True) # Enable word wrap for the label + self.layout.addWidget(self.info_label) + + # Widget to display the PNG image + self.image_widget = QLabel() + self.layout.addWidget(self.image_widget) + + # Buttons for navigation (Previous, Next, Skip) + self.button_layout = QHBoxLayout() + + self.prev_button = QPushButton("Previous") + self.prev_button.clicked.connect(self.show_previous) + self.button_layout.addWidget(self.prev_button) + + self.next_button = QPushButton("Next") + self.next_button.clicked.connect(self.show_next) + self.button_layout.addWidget(self.next_button) + + self.skip_button = QPushButton("Skip") + self.skip_button.clicked.connect(self.skip) + self.button_layout.addWidget(self.skip_button) + + # Add button layout to the main layout + self.layout.addLayout(self.button_layout) + + # Set the layout for the dialog + self.setLayout(self.layout) + + # Setter method to set the info_list + def set_info_list(self, info_list): + self.info_list = info_list + self.update_info() + + # Setter method to set the image_list + def set_image_list(self, image_list): + self.image_list = image_list + self.update_image() + + # Method to update the displayed information + def update_info(self): + if self.info_list is not None: + self.info_label.setText(self.info_list[self.current_index]) + self.update_image() + self.update_buttons() + + # Method to update the displayed PNG image + def update_image(self): + if self.image_list is not None: + image_path = self.image_list[self.current_index] + if image_path and image_path.endswith(".png"): + pixmap = QPixmap(image_path) + self.image_widget.setPixmap(pixmap) + self.image_widget.setVisible(True) + else: + self.image_widget.clear() + self.image_widget.setVisible(False) + + # Method to update the state of the buttons + def update_buttons(self): + if self.info_list is not None: + self.prev_button.setEnabled(self.current_index > 0) + self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) + + # Method to show the previous piece of information + def show_previous(self): + if self.current_index > 0: + self.current_index -= 1 + self.update_info() + + # Method to show the next piece of information + def show_next(self): + if self.current_index < len(self.info_list) - 1: + self.current_index += 1 + self.update_info() + + # Method to skip and close the dialog + def skip(self): + self.accept() + + # Override exec to prevent multiple executions + def exec(self): + if not self.dialog_shown: + super().exec() + self.dialog_shown = True diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index b10ef569..0eba3244 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -5,9 +5,10 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSpacerItem, QSizePolicy, QPushButton +from wannadb.models import BestMatchUpdate, ThresholdPositionUpdate, AccessibleColor from wannadb_ui import visualizations -from wannadb_ui.common import BestMatchUpdate, ThresholdPositionUpdate, ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ - BUTTON_FONT, AccessibleColor +from wannadb_ui.common import ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ + BUTTON_FONT from wannadb_ui.study import track_button_click from wannadb_ui.visualizations import EmbeddingVisualizerWindow diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 7c52faae..c56f3c72 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -11,8 +11,9 @@ from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ TSNEDimensionReducedLabelEmbeddingSignal +from wannadb.models import NewlyAddedNuggetContext from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ - CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, NewlyAddedNuggetContext, \ + CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, \ VisualizationProvidingItem, AvailableVisualizationsLevel, VisualizationProvidingCustomScrollableList from wannadb_ui.data_insights import DataInsightsArea, SimpleDataInsightsArea, ExtendedDataInsightsArea from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 4301e6f3..8ecff43b 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -23,7 +23,8 @@ from wannadb.data.data import InformationNugget, Attribute from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ CachedDistanceSignal, CurrentThresholdSignal -from wannadb_ui.common import AccessibleColor, BUTTON_FONT_SMALL +from wannadb.models import AccessibleColor +from wannadb_ui.common import BUTTON_FONT_SMALL, InfoDialog from wannadb_ui.study import Tracker, track_button_click logger: logging.Logger = logging.getLogger(__name__) @@ -534,110 +535,6 @@ def _handle_remove_other_best_guesses_clicked(self): self._fullscreen_window.remove_nuggets_from_widget(self._other_best_guesses) -class InfoDialog(QDialog): - def __init__(self): - super().__init__() - - self.dialog_shown: bool = False - - self.info_list = None - self.image_list = None - self.current_index = 0 - - # Set up the dialog layout - self.layout = QVBoxLayout() - - # Set a fixed width for the dialog - self.setFixedWidth(400) # Set the fixed width you prefer - - # Label to display the information text - self.info_label = QLabel() - self.info_label.setWordWrap(True) # Enable word wrap for the label - self.layout.addWidget(self.info_label) - - # Widget to display the PNG image - self.image_widget = QLabel() - self.layout.addWidget(self.image_widget) - - # Buttons for navigation (Previous, Next, Skip) - self.button_layout = QHBoxLayout() - - self.prev_button = QPushButton("Previous") - self.prev_button.clicked.connect(self.show_previous) - self.button_layout.addWidget(self.prev_button) - - self.next_button = QPushButton("Next") - self.next_button.clicked.connect(self.show_next) - self.button_layout.addWidget(self.next_button) - - self.skip_button = QPushButton("Skip") - self.skip_button.clicked.connect(self.skip) - self.button_layout.addWidget(self.skip_button) - - # Add button layout to the main layout - self.layout.addLayout(self.button_layout) - - # Set the layout for the dialog - self.setLayout(self.layout) - - # Setter method to set the info_list - def set_info_list(self, info_list): - self.info_list = info_list - self.update_info() - - # Setter method to set the image_list - def set_image_list(self, image_list): - self.image_list = image_list - self.update_image() - - # Method to update the displayed information - def update_info(self): - if self.info_list is not None: - self.info_label.setText(self.info_list[self.current_index]) - self.update_image() - self.update_buttons() - - # Method to update the displayed PNG image - def update_image(self): - if self.image_list is not None: - image_path = self.image_list[self.current_index] - if image_path and image_path.endswith(".png"): - pixmap = QPixmap(image_path) - self.image_widget.setPixmap(pixmap) - self.image_widget.setVisible(True) - else: - self.image_widget.clear() - self.image_widget.setVisible(False) - - # Method to update the state of the buttons - def update_buttons(self): - if self.info_list is not None: - self.prev_button.setEnabled(self.current_index > 0) - self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) - - # Method to show the previous piece of information - def show_previous(self): - if self.current_index > 0: - self.current_index -= 1 - self.update_info() - - # Method to show the next piece of information - def show_next(self): - if self.current_index < len(self.info_list) - 1: - self.current_index += 1 - self.update_info() - - # Method to skip and close the dialog - def skip(self): - self.accept() - - # Override exec to prevent multiple executions - def exec(self): - if not self.dialog_shown: - super().exec() - self.dialog_shown = True - - dialog = InfoDialog() From 1f96924a4053d964967cf599314b0885350a4990 Mon Sep 17 00:00:00 2001 From: nils-bz-surface Date: Thu, 19 Sep 2024 14:45:24 +0200 Subject: [PATCH 64/85] replace png with svg --- wannadb_ui/resources/info_popups/overview.svg | 1 + wannadb_ui/resources/info_popups/splash.png | Bin 58115 -> 0 bytes .../resources/info_popups/splash_screen.md | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 wannadb_ui/resources/info_popups/overview.svg delete mode 100644 wannadb_ui/resources/info_popups/splash.png diff --git a/wannadb_ui/resources/info_popups/overview.svg b/wannadb_ui/resources/info_popups/overview.svg new file mode 100644 index 00000000..5425b7bc --- /dev/null +++ b/wannadb_ui/resources/info_popups/overview.svg @@ -0,0 +1 @@ +2134 \ No newline at end of file diff --git a/wannadb_ui/resources/info_popups/splash.png b/wannadb_ui/resources/info_popups/splash.png deleted file mode 100644 index 336e52943599c974835883bc6185d835f5d1e865..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58115 zcmeFZcT`hL`!61QLB$+J1q2k1BGP*Y3q@%vy(1-b0ul(FpdJ-LKo1BCp$Jl>N((}0 zDn(jC?@bUw4?Wc6HyiN0?|XlDt-HQ!-TU9WT`V?x&pb2pl+ROUwlD8%D4#sebQ}hQ zoxFQTK@$c$)(e9jfE_&yo=CDN2Z3MB2>rX(ckf<@9R+`YmirFE_JOLa*sKkOv~7z_fA{w7OD?X?)^?5v7|avv_eS>4_Zt_h&9tg>(w{OY zTolsgq)TKEEj+H6cG~`#E{hu9_LHk8?*;`Y4I8@5Tjov;k7Q;%&A4>bKR`lSf~(}t zOPcC&UJF@?xmvtW7~Uh`KCe)deK0H<8S_$KTP<0yI#kc?!;z;G-~VdlvDEl`#8$F= z?&s*V$vc}uISOe)NBGZXNYSCz_{{L)YGduLu~tEc*29?Pxw3eMN%$={G&r#rythj>3NqI~*0 zd*r2?e?1X8e(jsmJKfl`bjNzbaP#zd|Ch$ci;qA2rGMiXlT{Zz^+*N2Fi$JoRP)mD zf(i*j?X~7yy7ud#OMWvFg=Z>hCyXagVm=*j^#9w`&h`UG0*3j^7o=@Ht4r~L6S9nX zT=>7^Q(iaiC)+Hm+dUjUH(|Quok1R5-gie+Z-IGu`1FN_2dmz}9v)(pRq}gpjC|a@ z>lfW>D{r8Ko_5;$Zu;tKQkKq6eCAfp76?91Cl^RyFd2DI7jsJogxe(xgpD0imYs~R zV83K%CCjcSsxF}JavNc5cgNcmq2;ZiZRzb`DQU$nFLzwVQwk7pLb#b<@^o@UqNF@! z*>~tlfqUpNKl`PfCT=( z3jW8Ckj4Ge17d-Vos-K>FTm{oXz6BW{V!(y$K0SdJInd^iGc3^(EX3rzt_Ig7_?GX zmr`)Hbcd#QS3#B?8ehuF+0xERYUfs5R8&a7+FF>`!dgg_S46@>oL53nRFKzF5Fu_U zAuKF_5Ec11Qg@LkH*=&V0wM*7^VtD9mLk^T0z#JJyw;M|R=gsX!UDYJ2rCg@Ng*o< zYjI%#2?0y+|D zS__E_TUc6%NbZobvXoMCc6Blb;PDKn$0AR#F#DJ~)*A}X@G>>YZ#2v-!)M2O}NcJ6eC z#3BV20~l)#=_w$v^Bk;2>b5Jw+|Ail+u7Msmi-^p{pYnhFefW>H**DZHv}O14-RYp z#$j#&At`~~=|kkyovrMwz5Y+FA>+9u1KI`?U>S~ zOFKp(Wp25<1e7@%VYM?)Kx_AvrL8&A1_97xPhEdMxBK6yf{3Mnh>(DY7_Xp}r4Z02 zQA=J63jqOMD{}!+F>47ya{&R%|D=v`ws!L{cSYQ^0el3!0_wTrp)Ot95fs;dw)U_^ zKvDn<;}wwP6%_nm8OFc2V15WQey>=D|NqAmnVkmz1~Q=E?lS;h01Ns50mD6>L9p}x z@Y<8b|HBag>HiM$KjQCy+4Wy`{f{{CKWhA6?fNgf{zn}6A2t53cKu&t*YSU)QwSu8 zf;>R7bRasf5+quOEmV~iU^M7oLRo4!cyi3;jsXf}*5IvB<-TiHhJE1S5x2YQibp1n z(J`En{L$er2ZLRL-Bq}$?b$ms;1gk@=XGh#IsW>3$kywVVrZ8-oN46zQ;Dle=nkE4 z`snXGm+CS*URzsnPG%akO&(j0)<~7sa-Y_{8LmM&@n@TXLrFMt=5p+$K=`qbFU|@5 zsdFXf#+}M15j=HbcMey0O2E5o_&a)*US!;eRMpg(98C8iY;Sj37_^VJOKxH}2gq?` zX`j?f(M{G#JPk@JcD8d4T*)>46(~Y@J}ovV@LG^XUWdIplOzn5c5e>jILHPUl#`RQ zZwD&_*PSx&;U??+Hz=f1Y2GKKV11sW;L6mg?m4SC7)pn|eO+~kmPgbEy>isIm%(vq$93}41>85TzMIMgd|CI5t!qj4;!4NY2t^+J!j(B-go3-xj1Wc#k>yIBls#N&T`^bPP<>ux(5&nQx)=z6c9mo~w2kmccGl)vZA(45(SXA4U zBQRL1wWp_Nfln8dPZkjn5ov!H9bM9w42Y;UYz8MzY#h|CtT z*!5ZGj1Cn*sWNXoxFJt_rtTM(v$m!NC&^KHE6oN5Tl~Z8SVuw&^3a#MJf5y96U7HJ z`gIF&WuGWNd&EC@H8nP>B}ZH>gTr9${N%8}YYllkfS%KEU;b%;6Myw@=HC7Gu zWDmezNBvMu3rsxBzg*AI2UyV)BiHU$`r^e4We$rGg|G7Li8(pWY1e|rXNU)2E{XK~ z3=Ny4Rf9Qt*k{L*tU5kdf_my9{c9^HU^h(J`9I)w%3HF7s&Plf^&@|lbU#|A@i#1n zj-8+em+_T>q8`u00rZu+?R_vQXDchKL}m(ia{EN5-+_{+-iKkA_}@g`|Il^!tw(vi z@a=aU+4=iC;}*(GjhKc{2s>>^z=g=lNI5b_JpOvP;oJwC8MSDei)2yGr+9cxcmJ<*o0cP z{<9ZCKL{oVqG6hEv>t;HuCkePFutj&(iL~<^C`E)HvE};P2Oifl@Ni{Ua|b_%LBWysk1(p%P(Nb`K8UHieZ;(p{93tX`0dBRX&;Iu1&92i)$P?8yTJX1QlX*@KK#|RD| z9UVin309M;UL<9o>x9j_cKJ7M@Dbo+^S z%{Pk}Ad?@ezpgA-l^wnPIS=4eJC!|#^F~SppL|E9ypWI(oS(mF{%1AewB}hwlvpLJ zv3=WkF3Yc2CcNpp6-fVK=aTB_EqnqmmPC2idyGKIWV4v(4-v@`&!>ES zYh!LLYezP*Ffta)vtRAa-O_z<_CWqkF@9@|wS2^!{>)H=B-!8_S)@!87GSEAdPK|n z=VfMCz?f!r&&d=PV#n}umMJg@ea-yX|2 zJ27(z{p5zh+3HG*qAvTws||Ujcwn}({lxe0-+x+ISO_oQhgF%6@1w4*txZ@A9dsqI zp+)z>v}0a812C3!O88KW$-a^>?;3i`%ssj;rCkqoJW5&G45UH{hCibFRVOd8vawZI z`uh4h8AN7PA8xgLGG(RU=ul`ld4O&0hULCmRQ*?4nx+0sOr`nHizhqT8*X-Zz=muUru4u()d}0K5?8u&@+fY5ha^fK^NxQjQVs zQ&UrOZp)*OaVu<)1`z7QGviu!$IV7&6yej{n*wWdQo*7;r%?BlUil(n_>6qs}+?h31_(No$CTKPOvH;Uk6 ztOL8jUoUxYT>w)@+S{m5(R{@yJF%FMJRb5LE|VC| z66A*py|dM!KY#xG5cp*?%}yoiBXG{4uU<9cGv_Ue26(@+!4jev6a^XKO-w9wFqlFW zeazvpz|Z^Jh5awQ*n@$`ifN8XCb7qn?nk%s9RkFI67{FnFM4`uHCY2u1zwPNa9Z|F zgeEsTQ|-YqU7e>GgUA;eDyh}0JA<7hWAL?-R*qc?4g8WFdy{76eE9qK@6r}< zt+;V4QQDg(H~*NPXF_TcH*6M(P>m7=Ty-V9C2W!wrELCy1z0W`KVzG?wDM}yUruKi z#12MoYzQ6XPPG@;d=9yXk?+9<=SxGJ4<1l_AOai)*>IRhTWz_iJ%0q2mfgcO4I&y8 z)8|W5j7wU36f_pxb;e?IyuW5$3#tp6bE&70=vLFm_t^{uQ8Yjod_Ux%w zPvn6rVi*@~*Fn^iqglvsFTnz$VEt)NM`y#QTuws~l}_|P;wk>c9??>Ickk%7JRW)m z#f82s-D!?kiNlaWX-ZDmimT=G*8;z5&}1##^;5zyOV?G*+(1rskYS3^X&XD6&$kP& z>4v_qoq@^*zDuj8cFsPNlaY~Of*+WTo^wMReAeZR>SWm32fH4#sh6IX?g_x!U&m+O zTW;>ixBB};&oTYq78I-%lXy=DnccqrY|v4R88|_93uOSLsz*2CUj@p>p4b8lys_de zr;X5+_F{$Iu+g6V>QKz?W^?fY@&2BS&SmK4b8CO-$=0(jxWLLP_oZ3<@z{xgUDinc zSc1W%Qcs1Tt>vE2eTe&s+Fiou`--urt|qrnEt>Rq3ZK_~&|lmfKf5|CKmB@`9@fkP zAO<&v<;1(wTnXWeGQdsR6&i*QVCnh(G;=%m!6(fIi`@e+flaBbwyy<-Ss=8*=k;Xs zH0Y+LPR-y@xv!a(5C(%LkB8ZS!r64)IS-gpazk9+N?1$`*TlFVHZkBkZ{~uTZ+uJ`VB?-PovEJc2nfa?61aev`9;>Z9oiG@8R*S97 zADqRsZYMBlyazc__*T?|e>3N}>0-i>E+9#`VD8_7pS$RsAl0jN@SJfIaP*j$*MY>T zEWgG-$@K*df@nj&bFha9>;%=a=n>|0I`ch%T|3ifxBucc(*j(*6^JVv$|4*IULZla zl5e2!F+xLR^#Gs)n}wW1Nxv7(I4-sB6o`efzo?d7W0`~t+G24h+@{Fsdx0l|x8G|0 zW4_uG=kpsB3q`uiH1m5Jmkw6l22qB?A&^x3qJquVAU^#btIN1LmJfd{D9GO413iiC zM7>jPz`qq}<@W)F;IQcH?tT|@W$8%e?IquJB(R(SjWH@PONdC>2LROBRWmhoI+4jo zL=xEBg@2>*nY$o~0*ICbhpWa;THq?rS?5X=ug6_;Dm46bfCORC!@+*}WDy`N9#Kks zOu88eS(^vE%u;Q{OnE3rKN$XIj>eyiPofTJ8$^Z#21c|t&tRBQL-7vjG20aF2WP{N zJF3zXVvj=50HbSU#=J{niP>z}1Yq-q?5>(0>+{$B9*M*~2PD8Bg65&bRGQ54=^h6Q zfWa!UNLw5#?DTau<=^9(DkibGduqXfet_(PE!z2gGNPcV@keFuUW>n`+H7A8^8*+J z4w9FbG=tttYLr;sah?NEwBvh46Iw!5n4o|u*)0|r8Mv@&oXm5;z_^kl4Q2w?e@DbC zA6ia*H3J+0!}Ws5l{F;LR_}Z4Vd-J3-y?dr{ATPT*#r>7Wqyz{MX z+k)Hkki~&~E>#pnQVI%yW5qeZZ%R^}L)|G1F+^O72+beT!Q`? zc>0qPzIc*mV^$S{M*1Kb_o*H|3OLs;PKlxb1_-eI&Mc_*J+|U{+6|K898k(OHuwU& z^lJu_dhi6KG=woK_j?L)j4Da`6nJ!<9dM5P03aIVX}xbdG0m2IsKmX7X`ZeA*u(G5 zy#_*WC4)$hS7Bj8KY@*7y$9JA7H^9VL{o^OgT-E8j`uOYmrhReO&WmmW)-_MqhQC_ zqt~8b+zB0fnRL%=|ExM-O3|Xfx(DdDlDwx3%aQeBy)4_`(uGcXn6Pc#llgJSNe#E@7P7Z830Ckg{1 z?N)$8`RlqI$sz?8F~TGn1~_Y402AcdcX({nMYQb-uErHJ@_Bz4)@?gA9T<4KcC%*Y z1n(f=ink1zT-Xo%V)!@{2sD}^G)L2X9eP%Ord9NTWxhehHA>#ATS;L3Z6%sfM_#;4 zat>OK24V*2D)zO)^{YH6xyaRpP2dj0UQ^g&gQG!>;Ncta&=S}Eht3bE1o3|M)b-6` zojt8fkY=78N4{S6&dnlxO(cA!{n_>&w@n6d`P)+3+bL?oNmzi)qOw=~`?&^uW5f) zu?3`dtSsQi-TH&ir+)%l<>?Y7>1WC7O}vjUt$ASP;_{F4#d3aWiv3rf*) zZB1xlVq;^=>hsTe<_63xLQ@Z3SM_{j|>-T-A z2Qd|%?ZvG?06Y1r`@k)8>Oj)a%JjH;V`xiE5);k&pVly~%i+aI{7bfQYaqJce16tYpfiN;sfgei(CUl?B9T<3w0}f{`2|y&r<^I~%2C50= z-f_7jhnVvXdDI!=>@&4?NEy{fBs)nE-=J{D2VwWU7OCDOfH|%;gwGK?JRo$y`J(rN z-oJW{T9l;kamM9k`z7XTe63(c&KIJxP_O`o`t2rby*5av)z>XJ`newV$}zjk4%#rKdoK~_FRTlc*}O*7!_MH*#vL>t$! zOyE$xp9*m#q_z8oKFIDgRmf(OOUGfC$WdTkKhEQo4I+7~+0*o9AT-|Pi@R+>#^YV9 z#xb0rdbqdscE%p3F0pthT(s{h#G>NVxC#&Y!7A1FmxuzLvyFnPlSfr+_(ERrKb(x$ z>JaW-?$Q3OeGh0VTMw#nt9b?A7LU*Zqf(_}w@DL@M^-}zltpK=h0(5P^ zJnTOAqCX54nhFZH56&L8I0mpN@SVsXpFegHZ=A2b`5TBWBK?5aucI4-WUIR!#ZWni zXrl&dUBw7iSb!9;1z~7jM}*lLj+q~l&q%k~x^^yOFGmR0&a4bBFz@bjUgfHJdz4B( zvlZrjlcCxqcx_>y9v|$!-Xe8l4CvdBV~{18-rZY>{;H`TRs&H~Y+%nLD2$ z-C8c*q!4yvU`Z?KJ|3v)aD-i+16zLY*ThdYr5j7hdx-G)XhW~9ILJ8ce@rKL*!wBW zV>;fc#m(4cGM<99zb|D2VzVH5!48}QHaaNyUZ#imJKvDEP{K2TdUWm|7>~;@&T>QnABWI~)E?cML0(&cA{ppPS0L_v%jjT+=wqun8_zwrwF zFlN6K(R4*1dIXNgny>5YUtD?RH5&MvIWX&jBqxx)WRu%tZgf1sC@4HQ>(R6{womUk z?7l~2j!+E@R-5O@)2t38$&f^jCN&($KD(2R55IG8lcc?~xW8RnblZ79IcG#9p;lwX zFcQ(=mI7QNY?(%>3A zIg?tl)zR6E_P^LgMcZ^#4aRj9s`}!hnM8N$<50UwUyR91y1BRa2Da^<{PqZka;Wh+I4BZgwIau!`T6=r5d3YH_MuB*pAZZq?0bxulhm(QzzVgal<E0BH}>+qcE!N+49 zEq$(4R@YI}tEnkhL%^0QU|lPVjOYBT^QN9Zp9g-q%szM#F5W2Cx$+Uaw8WJ>8Q2|; z8jL}elj}I+`}3Xpa2(m^K+-<pyD*#wMA zd(IDg@t4lu;z%x#WoLrtwxnT8uuE|ft-l%@8{^+1BtI&ld|?t)f$zn7jlYjK!J}uFoW2FV_yjWQ@{?VbeygeMs2<<^D~k|d9D?9dxcdj$oIPS}`{yr&>_94{Zp8RsWh zzA_X1Xl`F{Xw-?-S?8RucJ;dZdjLR8az zjvx{9KlOR`=4&FmHvCkN*w|QFcr7TNAXy=rtUbwBKrWLwW4693oJ*1>R^{`*U7|L2 zI1qvy_++Fz<9w7@G=2^SzT;_dwE$Zo77dZSnCWz&1o})N=zO$k`wykZ$*myo16O%18uH01<~+GMAzU^XivY;Ev8ICz{Ylr!ap6&gf4T9u=@ac6WX?1i#_^!Gn zsHCK(4mA%A(N-f``#45crC3M7vKQi4G175{m1^kPUjKEkg_N$c6dXyOv=x$gO)eHS zSm?bnWfEoVT95ml{eMMlp;(+ogThM&@#Mxv<7@C8-)BXg6mrI$8luQpjX z{bhXDimz-_Q|Jgm9;SsUdVImR5eC#bMjPzHrgbrJuAYT)$O7RQme@q-umI2prOnk@ zhqH3^CRW8@u4}qxpjQ(BZ|(3+kK|Vy=^l|Shr*r?`A{Q@ z+6ZsEYs zvYA9I-4Q8Hq(@P~nAVFL!989w(av`z(2}nO$r~0S2iTG$LN9=QsD!9xtE2(Q8(eGs ztBvT|va2U@PMTlRK$~=t*f`Zhp(l|2q%0*%WPW5QWnU`Bi^YHVZB*aSZAD*a zGxD;xJvjUUE~%f)Lu)mMh_aM^AFx|}musLs+;V=I?$MCtP_S}2?N%SIcx5HSay}1b z@~~EK94pTX$5`Dp01G(Zp4Tur8EeF>_$G!+^E+4e^V=BXWrfk$J^hSj^&Ea&L|Iw! zyT*=Np1R;me#Wt&Oz$rq=RKE6)G!HbyPwcYNnBQLhAGws!cV5XjHpZ_Q?L|$4&vb`c3FFeONp_ZQ6)HdaG zS$SLss*579`7YLR%I85|LJ8nxS}-U#%9f!Vl5zr%rGBPDV+7F+p7lF#Y!;)0Ke|Wz zsgktrx1!FhkjdGn3j^H#nNEHErQ?}j#`2a%w%RsNxcF>&Dfl{WZ>+GXay4B5W|c4% zls-ET2{~?HCGwa$x@`1Empl_cbwxfjij_*g;=RD|Y5kMv?IeN#B;d+#vc=S(cjZoL zS?XwjwhTqa-q~02s~evJ*CrkxI3l-Qd4S$g5L6s)bm-VCNjFse0na5p-Cij3`y^It zxhu=`##d;bR8DbYcw66*(NB*XdT~v>=i^w!%}S&Q@&m59EX$z&TrmW|$Y;7UYcu61 z7&$#nXh4NH=iQ(X)b3Ir7$~t^t0=R`x#E>~f7fs6B)5c)EmqZVTas75dQL5>_Tieu z31U65sddQWnfUkbi1iVcJB<8D9GAzhh zPZ!$9a-93bii|yVPndd}VjYUR$5xBr3qJg1%dKSRp1d*h@B080E9wF9{}#>5(LKxK zR!w4wU0vBeCrhRQ$qsiUp)rjMTi1Ry}EI01b2{X>f4WGryK*26XpUslCK+ewEE zK8~k$o(Pf3hu95+hdG4>35B0cU;o!|>EHYsG&JO6gn&Q!;lEF2S00C{He>h`wfs+I_{TZ(Q_zEK89w8i~!Q%Xat};h$VlLA!VA zqQpF35?F~_ezs$Q51OQ&1nh6p{DVjzcnBO=t)-Dz)sQ3Gs|x(g2(UCTkDwT{3Mt+A zPMZKcfU7L{!ov0@g|<;u^%oyVj}o}uO_B!krJb476(2c?@?yk^7%vQ zRnZ<3pO&6S4atBjuG?J6^d_!#S7;>2uZcnGQ#6?l`6ul4UEPLttTZ`@)eQBEU3g(e zD*@B!4JL%f@SCt?i1z5*Qkfj!N*w9h|hVmcAS$b1jmu8#E>p5;6lc9)W*gJb-42Jfv3+w)TwU=NZ5!;_wF6B zyFu9;U(u@_oV~rU9nA08CzgYmmD~Z9i|XjN-~@v-`$!l)5QHuyh|G`eC#4sl35fQ# zaJ`@;uhk!!&gZ#dFy09T>+aT&TqX`n^V?e`HT&N)^YoX=U8wgQFFao(d_ImXBYsN* zOX)5e`FqyWv;&3*OUcIF+y--cgQB7peAj-w z;h^a!ZfQC+A2{ z5>2Ve6)%}-OPH4yDVe`=(H2+n4do^L{?sU>Xt;^<8rZFuFSTn+xqBNhi6<&lyf?Po zU73SDF)$IXc%p1eS?haqPH9$DMQLeer+DesP|=C4gw+twy&*w7T1eER_7&Aoo#cy$ z<0}^V%P$(u^|09Z(%!4EkUlVHzbxx>ereN*9l|+fZ}ywjj^87Ps^jHmZcTyeZ3SP@ zh`)hM8Y{I?6Qb-TEevhqjQS9z&&vc<82S#5AihRFc%9x2%4Fu(3fR7fVvB zi9=gKIZZOILg}*{NuG@3$Zw#%TIK*azo(&Z)r@85|4t*6j zV+NWLWJ5F?0PL%vTX*0(NrBXpFjL8n;-)_u$u*zWK;W$)kt+mxbEXxAkEXvQ_%y=_3-V1=J z>})~c1xXSXCASRxnX!cgh-EeOTRG>Fm}hv-^chjdOfXC~^i+$PatwwyRCyO9nosIt zrax&7)+BW~2JzDxXBYa5Np4M#{I*l3N2wkIOvNE51ExJNg2=74)WmmOCK9CpxG+=G z)0<&!ZEd6r5ONKi+pa?+-|L@5m(8x4ZFenvkoW(5u2!?w8V^#>QaQ->6K~gaX zgip4qilV)-#Vdpd4OK!UaoW&}k)o8&_7VN5PRH|#(R*1&$D;*mPN9>LS;eIK!kCAX zSqs9d!mBA|trd?9Cg~RC{B+))^YV5vz-{MRR5M^G%}aO>-cj+-HoMp&4clZCB%*$Sn?H)lvSdt2n~dqWkpD z+Y4)p#DUJvF$-}GDx!3uO>4Hl37q6Vo1;2618&IgSV4X!63#qgz;k?kGA2Q~)GUQNqCB0lk7ttW18VnC%sOOgbR zdMLXR!U+D;_Z6zb@5!xC;GFu-ZB?xRH&P81LK?Se5v>Tg;A<4?#Pl>?h~%q7@Orp9 z?UqU-E!47MC>8{es8{-G^xCG@g;qB^c0H>vorTH?Cb1BA+@2_bxQg)BIk2qgR83Lm z$pWVsJ>Q?y9ZQ~`Z(|vtyg*W4cH{j7OeHoOK@2(ii)6W!>Y+>v=I5OtInxMMhA)@U zpw1x-N)pc==keqKRbvhe}7(71a?ncH-iJ1wLMboOlebi=I~dj30v*<_@t4C$VO24 zvC&#hTimqJ-l(@ldfjV0vORCl3=EFHpQXCqbL=UUCOD$<2cA9^uNgEtGFqMA+igW5 z#fS0YQxeI^)hi0_mPrG-tK^}IT%Lw$P`t=)kgVWh&%Vc%^{B(cRAtgbH_NcY7!)7O zllE2qQRl}!A4%S*6up(29X9H}lxgJuwQei|Od{Qpn7LDiar;XNPgtMpWebKP>Z}z9 z5L0qPa~bR?e?QB5(HjLZg+JEIyKnW;Xgp03Owy+XtiJ;kV>+RLB11Q8qb{QLzCk1g zoaK0%QIRBg5kmYhp$p8@(7w7S?d+D|gz+CsvP}P?Uz=BxlVTR4d^vStMMB zOCnl{_9fF$NrvZbf6=C0;y2M-wb@}K-TbzBNwI_Lx#CrRVOWFpQHedgOb)y3V*-i$ z#+b3^d`tJ%r;I z;FHW^GX@p@+w;Oi8I-%qWG8m%1^dX7=}D)Rau8bi+Jbta$c{tS79{FDKS4LUmxDi? zw>(=A2Z+O;{pBO`5&BD`?}1*Zc)ZTlAndmti@nsY0^`$rTOk5q!Sk zI&{3W3@_&r()?+9ltH-HE-xv((XnW;KJYpB&dJeqyC!kg;3j;^$lrcW*q*r>2avfu zYjP|irxrm`vJ)~7FD^#zb-IdI?EUg+6eO=X6~f5{V(fB%z=;>$Cf7;Qh``41-=QrD z&ZX~4cnE6kB_>3g(A?Y%b*;_NLhqY9uzJbhW}<_-;JLbm3fmN> zFLifNb7p(>p1P_Yhg*;Hv24g5co30%2Co3d!?=MO6q!G01VAsQP7+g>#$yV;Fabg> zZP*!gYsRI$LS*mXx=1x%F;Gl$hin&3xQ=w(2}%v!q8>-li= z;nu@AYy1&vObnE#UEAF3U8Mlj5QiqHZ7BJPr4wY<9$-{9neF8^XM!Z{I~FVr@!+s4 zp#mz3=0b=cN_r8325E}RXXCvzg=b)W$X5oiizR1h^I;!ueT6wEWYRKvc6HEO6)Z3) zFDF(Vjkg5x2FG%D;XrSpL;MinDp{V=kCKPOJ(Q|AL+ZbMjNmgWwC|v$Lou&J9Q(Jt z+{_buDTu1aSMm73__Z}mNXL+Wx>KKw=V;+0n)DzO!VFG-YVbl^(Md%<%|0AvHr`(h zH`B|)HJvy8aXP1hIu9y&bG@I zC~Ow@Oj@2|wRvjhTMRW@VzzY2=jWe;s~HsxltD~FYwi3?ia!Uke5<%VKKT2lrtjF( zZIiMv#Cs!WAA5W4onveXETG~kAZl=4!gq2Sgdh)PaERMovqslZj^#EUj^(7mP+C@B zh46dhleLuQ+w>-)!ov9+?9|POR_0efwpq;an3MCEVwZ$~U>*`kH$u zU6~pytu1FTk}H#DCwb0N!ZnZ6=&i+YThUtJ(jHn5KkSG1I*Hrvot@YdR7?WI9O;DZgL(f(EX|jVJ5!{WRWBhncnEBa-BS0T<hTf#1xb1Yt$H;WvT7llsWR|$#s`i&1!H}t?4OwVJU zrK#o*R1zciDzgZWxidqd88po5oNQxuUMVwedq(!yzNZPAkFTA+W4$^!>N&jawKC71 z!*~dud#+H&=&w86ow`{#-OV7Jn!o5nP~mr+y?OaQy<%2-=ea{M`_w+Yy>9IS?AF znxpuFzp9E|P&x)a=($(aH|qg*emvMm$nL*BxDqhkvuLwVXTYL3m=-BFyXp>uloAGO%-AmSmydI8Nq#h;v&4*ZP`g(MNv1D5hVp+JjtuQ|6x;fsa)X%VcN0HunY!0|O zoba0j^G#P@axEWUb~|C>W^ls4M+1%ML>BQoiZI;^IcirIug?UJpGM=4*n;9tyPjE4 zfeQ144rd(3*4T5AR+WFMPj^Zxvcl?Wc`=T71B&F+u%i|7(cTMw>S}5yKsodxJYz~O ziE~Jp#f%!y*6%&bLTNO(bB$E$l+vTWbcE(I9$$RD-udCf;hLrx@d`JftSgeFuG9$D zRPJ0E_CZE?P6yyaTB_tw3$o|T>QH*q%**RDjzt*GzMZ%NHvWLH?ta@Cxbj*W2a5;ObAah8{Xfo)({$X^S5kCza~ zWO##7`?6!v))nEyP)rS5O6Z%VA{Dl>HL9B6k2a{AWSUQo;YuU1>DPric>KFD)Ru(i zt4!&!cODKNqV7_$Vc@i+_%AFm%ZDEv_e{K|!OYC;ll~TPWQD_4*)BpBnsBW-|GAyo zY(TLHI0P<}Y-LjzI31km`5>c1Y8vEarQGZzTOG9;AC(0EfV0JiocV<%2N{EL*1IXT z9)(%P#fuevv#XyZNkd_QlTsA+m~Y3h(l~QrB#w=%<*IVyeD?t@%RrNJ(HDp z5v{#}`i3Mqu+h*GD3RMXX}ms9BFIj9b%~%*f0DK+KCYn`*9kv%cttVg&XMb#T|EMP z(>b_VHj2%az#sqaoo6xwv=(+-v>8@-(!xp&MyFtGauiRiOK$-rDb z`-sPsA%?JC6DW}}fTWEPN~xuKvXovedH;nweC*0qT+*1hHS^HY@zU@xzX}~av)i|C zdjK1$xXwO!4xW=?#~jITTDtZs&@*^vg_nS|-ikUFb;%9&`K*7sXVT-l^+tZhn6RGi zK%Kex9oPXZz(hb$Fjw!v*;K`x71F2?WzIv#f$*A#*n6vORVZMoLw-h!C-EW~~ zepFUgh8;e9SOe@+Dp#4fXUHcPn5j&5fTLs$S->l&$pHOu= zOr3sXK>FM-o7KLs!q5iSO(LbgKhBl(|OQs%MI%{)}jy zoJHdu7@C8nVhJ{`gj5oiKT8YLWY6^90>VjGZw{^JQti(EGJ5?xX3T#^v&D6PA zjPmp3o9zothwKZ>9S$`&fd1z}UE2%}FFgKNO2lBxEV@q@^w17ZV0o*J>)Um?(m8#z za=fIiDX9+Qv(;uRD=Q)I%gHL{sj4;X9J*3{h8XQXwRs-kb(v_P-ReXqu zBg->B+(_EXQ&&FMe3bz5X7X1G2=eLe71l*{NuqRq=uEt3Dkvzpps#fV7GQt6AW3%+)w3@enlkLl5-9h2#7DF? zq?_6hyo?CzCb1eM%M=Zre%;?g> z0UIoL0rlE@X>#e37kr^XCkKel8b3e{c&X(MAggG|txeq-R5~17Oezo{CG&#_jB(m3 z&vx&+!Fo%uX}<~9Vf0j!XjbaE@Inn+0zQd~3k?(}VJi&Aq_kAH>7^zThD0BM|8h|N zrnJ4y_mi7dcz(YpO3+$v98=TKD3@Um&2SBoBmuz5*UQvjxP2y71F!-Ce}o3>dpoVh zRW{P=kR#h(1tifAq?qhshTNU~ggeNu$PJP-^OGvFNgejvQ7^TW0kV-bRWK%FOj;@x zBBG)zf3zKj1-PCmNWwuf*kz;a4795rt7&L3#)Kl+;QD5R)ncSnxwgFs>nH~Pr3iLzCJFj7G1)8;ldY?y&jPyNnhVun)y*zS2rZ! z1eTj4$D5EX*8lmQ@gpML$V6u=i=4dQ_e0T8Vh5wM^7Y-i0$*r69`z-+#kLdyPMr4^ zTuub8QeIAusw_#OPl;Gfw+I2gRu3Gnq^7h@_FIhreUbzEw7Fm6*OlC~j=`U(5YDYz zYW~|zdt@(>wz#>H(>uk$zPn3C2a~AX+Dm|P;%CmB*_I^9XHFE-UPZJnz!x4sJXw4+ zt#3cuh?39Q2|=1I!AQ?JM)cAR%?Rr=8mUY0@aOTJj$-l=sO&{dsr+EXzWVJ9Ecll3 zg)%QIxxc02AYe{mLxo`W9s#D1ng&gkO`U?$5U_oWajgGr$*r5Y7H^%uJ|6fbBWNW# z!n7Bl(N$2U9Rp#?tjTIf_SGF-2%7*w-!H70PIa#k z_qbgtX~8H31m;(^OibzO$<;*vl%38norS50*1xZx>$DXal$MbA(h~U72ERTYLkta6 zG3A#e={fb}AzpF;9I2D*0;Wt_-6>!+Gb4+~B9pn2&+8VHo(vBUpH^c;6Tm4-H-*e5 z$swb!i>KaUu`<;WKo5)$&O-BqO&lv4^50zI7)c9?EJA{_jrm;P3E<#od{AKeVeK$+ zb0GWkH}S_MM(*Bg|4Y_oU3_-uG$pJop~d#1UUiA4N<20?IxmK0<~cw;q=K4uRyKBN z0XXI2t6*(omu>a-DYNyo?kGw^JBS$%qX=*5ddINNce%2dnV6zQ5Z&$&+$kLLC{*Q| zWk7=}V}K^SAS2*P@Oir(Y`kx;_~2rcrqoA!_c3hO3Wb7c`O7>kvq92KvoKLprPj=2 z!2JayKMWS}G{Ho%l&sEa_YGOH#f*eL@CEwGo2*^kSL2`6<5+^=nfU;8XyA*p9)2?D zt(b(QATYRk=DBPavWO;^?nP%% z6n5xZPZ*LOTw#VXgSknVV@G>+s#?n^c^Nw=JnyS0K9H$+g4nhJ5cGNSTP8Kr(}u7f z7xZ?e0k~);ArruQzb9x0*VYB)>K0|(t`E5O7Q2+Ao0XDyZO}ST01rIh3RiL%;3!a) zz7(*QD{BKLbm^9pgFe+I!oKIA`W&ntd_lfd&6UilmzhZix};5hn`w~5fb&kVL2uoc zf!ivFx|XRoXIq@|-SU4_t*pE^+_Q*QmkxV-5vF)SgLljTYQX<&g30VAeoY0(Dp(LD z;DpO)Jf$$fJFXKG_<3h#%}Zj=gIiUJ*W$F2qg@H{M#ZjUHtOfc+vSz2vUIN70_SB= z1H$RDtb26nlXlxLs@%_)^UH1Ai>m2GQSaV{Oufexz%x+7kp*5y zC0TBHe5)ry)4b&K(yGrK7k=4ijYym8y^i}iyD3YFC^9-pM}A=@M5vI2#PL70iI@L&?+!$!m+Mu!h@C1j^!f1mWQxwKh zhMhf*R^#CS<_QAf1e{|}o|UWX;zuAJ4=5Fvg%}3w;IP%>0J<9#7|3R}+VaJruWVt| ziBiQ|v^{Y-rw23S$GS+7o-6hFIz%xdQhyz|d=vC~IQQ$wfShEpJFh@3I0|ff0j|ou zBrHPWAH!BS=79$H)t{h63*E?N7LXVphYgV*n0ZXxyw}VFUcw%Bf=`QsxpX+&Vl+1# z@dM>;R=~R_L^ByeZ^7;WKy`~}^#=07!b=8PAes)C%G*6v4F8GFt=wW{W;SAlE9)f3 zVV6ipjvR3o24`t@LO(zTM65Xg)cEz1Be6>qO_gbNXfF)JVmoPVz`E}HzM7PX@^%n; z*?k0W?mIo%sd~d2V?c5#4?IIK!}Upv-C@t{9(aB@sHU!dG9@B3?T%y-A4moE|1Yma zm_mN0w}YeG>$|LlnEwB<_nvW4UeEh5i80uKpg}|gB1F2NAS^||LWv+ET{_bH(wDv( z8=W9cq$vat7FelE2LBuwA_7AGlm->}Y?O5uT%N#c z5fUP2(Phz3+4vqC`o1m*8{6Ry8n0S&jGCe_d>f*zFMS7ok*XyobKU}@r`YI#tmZz) zk3|K=uY@yXj0XZWjE_V3m7W)G4wRf~1tP*Z(WA) z>n>6rJ}Y-9J#nbOe1`mnRf)&IJ{ftEpzqEZw zUKwxh~k5|BCt+op_-b zNSnc9gOludrxN5BLGwSv&?L_G;PLs&egF1UDY_R?5Rh{r56iswW9cq6G^pzWsjhzw z&i%#X#x_&p#>FNn!7P~zp)E@9VwwJg$eb3N%`HgMjmUzukz!Plv`#{EDLJ>iEs>%vLW4|p?@<9A#0$462BeU$3@ zV9>Tf9Q`?A5vMzFuK^7Hk;Gj0!B#vP#cVK!HKqED_fsqwhOql1M%QO|M|S$cH$NKS z#!h1B7CV{$3URGVm)ThtzV|?)^beY1{PTkPdV07)PxU72P_9%i(@#@VoIXm=ZET7e z6?=|!n+Nn0^zq^q~p7)CmMYa3%aM~>qc~8j} zd}fT|^3UesXYYPK)QMLL6|6;Zt^bUaq+bxW8-tHD9{>1Fz9G$bsa1y_?o0L0Vh?Am zPVtxeg@p<0J(RqN?6rs4p>0x~99IsL0o39@CHY+sZ-m9-639>I-{Im{!kP?ktNtzu zd7no|Cm5Y!;Q%~t=2zs8)KJRHem!fzmR>d1d&@miv2ClKC(8TCoP5f|BQ#K{(m-r1 zJoq}5S9jKe9ZKn98A@v5!V^T}-G40ZMXT0;gUaziRSTZf*b_bP33q;miohRR%1Y=! zq}`Nr;$oqEN~h&ZIBXwPMc5vAY^v4DXQx04s_vS{01X}m%`pDyaK~=k;g(uu6* z(X-tIeZ(n)7=1X?qT6j69Lnf+zi2#^%zU?cvn(ScV;Jcp^FK=hMcAySyO)qZesWk< zM4YszxWA7DhUCEPPX6`XXHhAd<=l23K0d6gN3FI_dd zSVn$Ie)l)DFuJX-*sN&yd;|>3`0)?9A8)}lI7mK(E#nS6(rkEJ(K_xxgr^S`pOtX` zUYN5~Hr@5}YKnx|KS6i-4=o#XlgZAzd%P_Glr28dOXZ4@nqn&55QCgk+5er1 z@bJa5$8!Dk)yZ>~m+dw(R|Is>ijmKd!}8H!M>@hYg3p(nVh7T1{rw0lo6M(7?F3BY zwu_3m#X#+}Ij~heJ`C;!fMcdje#{=*jkp6 zA4;mH#9Y=bnC<{ZcwGXq0^_g$t^HLPto@^Vk6hc<8{VEyxS5h*jhMp^;T;uC`Y!%} zB>W8;W;&VOZ<4`?hD9H0FaMio#|G`W>=b&Th?^hL(Bl!v{vCoPs*_f4Z@CZC@Rv&8 z8?Zbp6@X$cTx6DlRTLtzR=EB)n(QjBt_8JK^@b+*brabsYJH{Dv@(KM?7C z-+YEV$%jd6`KR-|zj%Cf*Z4~@1N1h2g(XD(HnPiJj}Dj9k}`N|5T5SZM@)h--HW9u0xGIkraE8{?#*NVueyGHWHYn&&&ZMbD#`$2MeVe%*nx%5 z>Y+@czEj;$P!kIM0}}H3;W9S@?N^T-`n>Av+)51#k5>i;Lr*L5NL8?>2FF1#z546n z=aJRY_4a?6`%xe;4dO{9X&V1tHEL5RU5F#DBC5m2qeY%+bP4a@9a8EKZ80igHd!mx zn0QqgylHqllP;BH^kRkbLF8z>?o)O`ZX!OZ z1V3o2FwweN^#L6UCW+Y@coB*}UO< z1EE4NBL(dV%U0}x_&O|UE*}_@aSqL?Q=gd`jt|MzDzTCqGoQ~FoheF*8Nm(@kTl;$ zCvzWoBPrQK1m&sv`=@@~eTB9yY)D9ml#4>UrqqFIRb<{GgoH$P$sbJq2Pi6s=3T1c z60C7*UVSxHhA{_*v^u@E)fE`&Y>VqHt(67R0)yZrm&=|&FCF|h-)Gu{e|e*qU$?+Y zy=}`oYV&f!95AO+E!u0{r=K_m$eV z#6dCM`2&TIZ27opG(WhFtmotdnEOnsSO9L1Xl`MRj6@(znZEqDdxze__hTcIKXtV* zuqRhr(Ry=VM#ftuOuG-JpMSQf*_-vjzlqLmJgEhl?1i715fIn$0~fqP@uc$M)Q~4{ zFcex6f-if+L7;Pdxb-6$laUS%`m29aRuaBQHyd{sqBO6dL#tY`fJn6|ekDZn-KErP zKCL4CrxqKpW}eO)b|`W$dNNYXs;&9%LK)!38+3=B$9VJ<4UN4rkA0S+6z~#)&cO?9 z8(NoPg{;rW?)fqQxa_Z;h+0`Z5Rby)4sN-7YMy8mm!Wj{GWwduRN<3Bo!n7&F)o!o zlOJ-~caHmgA)8-D3)Oi1}iqGeqr%joxFh%Ve zcek07`S?z&c&)cdJVd|k?j*mI4A4wEzx5xHfqHs(i4aLY_K!O$ogU+D&zok?a&|Ko z=~emp67?@&fbL+T&&}jT&`+}#xQ^AdA5mi^+ye~s*Vg_wejVyn4aMNU)ASs;euL}I z*pT+o{lDXvUn5Xnw_XPiM%ggCBFGxTu9X^@$TG8kUn8`W+AF^lI%`INuYzkOewMVf zd+WSwJS3m+Uckx~cYdfh3c_y{Xt2q9vkWW-9qf+{C>DHR@sZ9A+u*3dHJdR^X8nyG zKT=@b&G;)dHO0Ir)El-ci$8Fz_C%r09FX(!NciLhNsI#nTgk!W7c-N&9sc5B(F%W= zC?YEAuAkn$l|1wDc50-StSsOWiuYQtHrlgEJ<)m1?gD{3uwU-kzL9p@R#Mj4N25jU zPRuU_jD3wmdbDq!e1rKnvNGcUX!Lvl{wjn%_bu<*zmd`4y%=Np=R5LtD4rTdMnvm; zr)tnPOIg_{h{u3v(o_OR@3o0>KeYVES$MUpkTHc@kK;G%PS9!o{^>E@_8fD4eSKK$ zGtB|-BD>y-lOpaivcr6;{$!7q`dGL;57@AOem+;!erUDIxebTwNs4y~SKEGxth7R< zgZAveNI!A+lOXG6yk$%OR%jKuOVt3W9(ofq5igjbtIU%c0ZHG0G^G(1pKl+ip-6zj zEhuge2E`!hk_G&&A~ZcRk>RWCaUfLLA+ubBSt|S!G*dRUeW8DU%QH@JXNxH2P-(*( zSYC%PQ&iI;>gwt-vbE6vq_4|g%-Y1nMDR@7RJ7C(bf&7GiZ^lrrQMJjngcrCP@`}J zdhj~8x#t5Ce)$`W?bvTt{3Pfn-Ic6{NetajcoBVem8<0 zS5DMdnBx$KR29^@q=v|kM@Dw-Gt9Lr%c<(y1;A{@j}6@niFX`MwuX!ox=rt4^WerrlK`x>gr%%pSu@fSgJZQBSk(X*N)L}|8r#5;`RA$odh6{P83-{ z&6A$sdt?CFBy<7Q><4?(10lNwF$z|Sk@QQ3xgnJj=8t)OMa1nJU<2+z;_FwhHhca3 zVIHQ=ZCu&~f+SifnbBhfg<}2N{r(ZT@cwDg5YUkYe~!@Nx1Cn{8lQ%sEKEvNbRCy= zmnM7QlkKDvWK>4r?(j>04;RImJDPS)khQ44OmmO`kbL(Oa-zV9XCV{926>2u>S0x|F!Mr<3gUs${7WXGcfJlvMaW!aOlpq7D#B)4z}pVxy<1AsmwuPDHqvGHM8MlDR;56vn#oy?gnR_Dx+T}SCTj4GM|8r z-8e4OZu^tQT8)>X>EWz+NxL6T#1O8fy7mFdRF-r{9gI4}^GA z+(ZK*O{g9T=|Q3z$nNKvec&fPK`!1_`E{UP+EJNsmr7W-V+!&d@#8}uIEuZ;m{WZh z1vmGn-ycVI;JyXqkBTpmc4Gr$#Pi@%%Ej)qOf|(9GarwnF^AMq7lO!|AXMT_<6b`h z-CvzJD`>b|;=j3wJWdzda#6s*#{TL~3v_OiNk2)qS@!txW9I*U_%HmB5RB;D8{SZ{ zF@I*8_qY+4z+{gNsE*ya8-GIg?NPc-TX5}24)IAQ_wxG#dWwp{^xhMQVcRGQrmgfg&CHrxBNda!+l=3^S4K%K@pOfv4(y27a*1K#t-)?gs3`yVPq zh8>LaVpY3;C!-J6b)Xg*;awhbomfrcD*N+HeM`j|%A$Lj447Dws2lJ5#ITNM{_!gc z+dDg`IY=x;Cb5u>sX5WRb@55#p~~JxDF&(&A_wdkRnJYxz-Y<(w_fuo_4rxauDlKNrGqMaGB&`l_d*UF_M~bg25#F8Jw<1d_YZVe}dXvc@n^`n~)s z+#0~#jWU<r2MJnGYB~kFzUKogeDFv4km|-h(c4vNP%~O&@lVrQlGxy7Z&3ust^Grft zJI^RryJCBPfe%vo7|5e5Jb(V2Q#t8$=1!>lBHQ`%LpG2tx#4zfsBKUaA1Fl9L0t&&>_ie)k?PY^c^doMhnxkwo zT2DY1GA~Ezm-1dm?xunJl9X8rPzlb`$xnO#GdGa)Sa+i86$ar zp48F>E6k)aYGgG=3+AGsa{Hw5KG}Qsx$1$%JsJ@f6WdIJn_KVHTfryJyO&{2wj)10 zre+<;F&z;n*+T;YNc^ejftB5t`rG{qk*y%eBt!t2L3QFCo&;#=kZEC_1TO*Dr|nMv zCAzm@VFjwr(nFBq5kQbrgQlTXCb9_GPG#HY-UZ9)z z2REEJ$;Uu8-f|nB1bFC55?cY;;-DNje`+cYEiK8OlcnCulUnKjKoWepF5e@=D3=`5 zj)h(KvWgF@0LV*!Ase#}wj!R3%Ay@|!K+|~%(GgUwyn^}u6GPK@QQ#p^IzhP^2{2` z?!uXYWP_#xzPn)KbsDGzvXu3CBY#Onw$>Js-TkXGnsC>p?_D1oG@dUff2*OZs-J)- z4Z)KV2fjaPO1{n0UiP!@6SZ`XF*nF7cBkD7lT4N`Ohkcfc{rdDWEQ-S=N0wZ_uu9A z*@`esfVK|NE&{yzQ}%MI<GFq{#6McftVkqSD|LS?g*DS0F9_ zl2p1WqYV|M_S-wTAH&>DJ#Qq5nB0@4&?;>q*LF7M^OiE%LR)`hZN=1aZKrwVC)luJ1K_71Tzh^mUHCk`tDMREcc;`wYZm0t5p=Vag2CKoajLRcCD^zW z!?mQQD7$^Lt;CYa=l&%`xv=H!Db&6ct=Cd)?$_tOQhQW#k#YueLdlitmgF*&-!Z1-tyonzW+8YDa-?3`}Dn4U1NE&&q53~5( zEoJAzUfhcN$MhuE?T6ya(P-b@Eau9MCu}2E9j9;7(>wKtEe@9A>TO1M5~6&C2{~8l zYzB(m?)c0sD~$^ZmR4?1Zr_~fO#aJuU1~p2^>8X8FqKbG!}kLE&)?-C_otv;Zs!l` z55qlq%`+)25Gc4hYM>R$)}ZiaUc|QVccdg^t&h>k$ckG|SHy@*86>gyZ#M@!t#?OE zlEZ}-0)f?cPM~6+>!&ixxn3z`ySQIKfwUo1U<2x0{W84cB^y6TB+A&RMH(nt>nQRZ zq?+C6mp*9W3L9M^TB`K-Pv-8s#@wnobaP~PaS}tBr|Y77I`^3s;yM60nE<+P!#TA_ zzqHKir8kny-8SzkL9w}|frOxOP$5+Xwr`aM$4M<1`c|bc|7xHmOA+}Zwmn>dK+!2a zaA%pOlTA^?H~_zJA0(e+>v9iZhLx z?>I=iOJL2cKwMF=X65eD$*E>_(#}rkp#)3(h~uc>69)+ShVLmzmWq;gN8?vUPCCK6 zT_A2Kgy^*70iECo>h0^L-J{jl3^DFB05bC$kT4fKYV z9wghS9(h3>4+r$oZzYKXxYPkRY*4BKg>_nyTYIE_07mpdvD5N8 z(NLi55=}=-J8G8<=U|Np>;BQQAyufWMTWW|TdSV5-D8W?aWc~Dkv27wn9iN^z(F>~ zeQ&K_MpW@a9s+&#oE2vX2#-1to^)bpr0n5{Rp|9h5e-#=ygzu5fxbNg4+QN6pi2vi zG`1;D3WHNn|?msIEbuuO9O+kGS8v~8j96~0f?#!TnatY<6O^vPgg{RVQD ztW-u3F$m-(khw){&mq4aK=x8lUD1}q#2Hg5L-O&IjfXOvCO)?a3GmGNxn7AE=+o^- z%eMj$4!H^I)3p)|N)a8>;D{|H@0;Zsrb(HL=(nrJ0j=JJ8Jm7H9PL>6Pc$#`2AULi z&kQt%>dv~C_5T~GrG*rNfi;;S<)lJ@bM;JNF#8&D4&hHN@1r|c*t|HxzS|5HTphBR z4Rg1!gggSclJgfOXKlvoLW0y4ceHbhpcoFaoxrFWcfl!$4l(dts$lL58TH+&R|EBM zTTQ;;=?bzvc3^;1-gZ#Owv6B-xKp$mZU|kr&eK~0k*YF#&aKx@G)7L={D~Em$)!gg z)FRsmkQY#;QT!F0jGnECl%~Nbpj2iU7z=qQmr>b6Ey1n6?Cu#K`4{iESy@gVFq7H}&Yk-5jScSm+ruGUPp z94b326fD1_!v&A)?!e_XLTK;T1qVt5$JiYRn(hF+QmBn!A8X`EP2y7rNYM^ggoc5h zAjCqUb&tyHkSFuLJsHsb_Ge7X9JTIG-=C8R&q1X|jvD>`i47x>fSEluUkOdQSbOY5 z35$M&a%evfEEJl^B$!%&#aED^$gFF|b=1}iWGZ7VO2_F~@AKRH0~J5p1yy4}d2G^Gdr}c>!V$k{bZ9#EX8+7EoXO4U z^7C;`p$Mxzor|S8IWoEhnpXB@!i$ZXIh+aGml`*UTbWo65wV6%ELwswQGBjuS8(Y}?a)?_NY z(!#dyQi)ZmM9Rsd@$on|yKa{J^<M+Asti$)=mWAq#$vd~KKnXSRp zNTM_}4cHe{hYG_{Q9Ux}UW7^!VMQLx9yebLO-;*4HNS$N7L#^Ky)ygNYE9bg zV=?W}Z6FTG1V=ZXJjQYOL+b5W?W$5QxINoan!~22=xN6@*#acgD^toAMG~H5XrlQ2 ztN-u+|4#z{za{XE%KDd}W(@lJVx(p^gQcaGrQ8&gj)^@_uBcF-d_du){UsU7bP~O7 zn3T_Tp}PE9kH&ND{!82&lrrwCiJp|IFttiUMc?+Xb)98wUP}owSKI*h6ZRqDkqy{w zWRAKlY3Zq^YC8>n@U$a8|Kv=((90obk~rt#F> zaTz6p1#6L6?|XyG8G^B5BMs2Tad|J{h)iK=xMiVYya9V#TX4ScSeCz-X(E=-NRWQR zETBxOb|fUYjIOXrD&2v3M-m5-#eU+G{haZ{mrj^jG$iC^!P5uP`j~WEOYw!pN5xw9 zI$`x<{PV~6Tiy7k<~CFnFSPoXaWqNkd>T<51J@&kWA4UiGh(+TwE|O{quWwNIz8zq|Jgucp;co2LTg{5`U?k2PVX;NH?VW?U@`i& zZ`&{KMb%AZnYi%hYK6V@Rt%`nWubcX$;`RG`gy}=jbyvF7B~tx&hDX0YYB>x?dB7S z+~#OvU+$jP%hZqXDg}>wo3)<4XQXhVC@*Jqo?LPEw#AneYm8Z4VxQ~au#B&Viu6X? z9UL^i-|r&=d^)4&yO!CiLz-$C=uyr#6k3m*$=jgrez6h%w)ZEhS6tK>gKs@qx^aq) zt(vuI4PxdqRgV5_J@nqf&53HL^_)SkVax1mK~d1v(9Fi;OLD_!{g{DxCPsl5l-5)z z0Iq`xsw{R4Ty6t|aIoXXp3H26-&Ukl?Yj_BpY*N3c{*rLrtz;$`G5YMiEeSfI+Vhq zx*nai-s3h{<~}#gm~fo`tmsg}(}3_0l3dTQu(LPdo0fVfbbzz3teY?8OQmE2INxdX z9;{2N9~oA7tt}*7MRRT4zxbMPT(d80YGfx|xgq4_;ETvRMZ!;?K{vd+#4DT4YnDBH z&fXk2C62Kf&gD1KEUGzzO#K^Zrk&?b5DHYSp{S^I4RA-Os#QcwyvKu5atI1jCzZ#! zxF-dZGjkS)^AS zqTvzt^UE^%fpnqx2To)Tfg$e>*;h)?DbAmxj5^W1pp8#x-2CM7pTAAo!aE2pKC`I$ z=JQ^aHfZ{gIpL9T)zCbcpnG++yJ0DX54R+uUNKHjPwk!Fmm{WzPjHAT43W>uR?5m& z%*xKsA~))*TV{p4W7AQLXQ8*WoJe7u4tYwK^R6j0O0Q0O99~xWKg)kJ%u;OLZ5>jh zfNyANHhm~vEySw2&f}gUJ)GA315D@W zPP|jgQD*XfKx0B{NT<3$*h|607koa+vY*e;W3-ttF{d{oTgrB{L>JmThaT!V3>H`{ z|1~$yKRMqbhTHFqJK>~dw%Wcos4acE+tZ#{)alzMeIWk-G^E4LSIXx@ic%4bt!-8p zD84wXR(YCf!>o4XQj?UX=z@UTXgA7rxPNK|>!LN8Ra(&2){w7QJE%IA)v~MYLOIP5 zDjR8QIvL2a$4IW|XKKi*^b!8sKVyVBNoRIDuwnO^xDR_=LyMHEiHeHmI{&)LJC;GI zZv`_FCW$UuLw&q~2SM$KV7*53LT4NO&rDAirX7dQl+hw7j^)++vz&txHXG=!EwwY|?_}p12dQ!furtCF|wY4~{$zzmXCeBw_(J+a=Oe@LVN=2I7 zmNFt%wH&*mb)gi!Hg!5zB+o9aL7{D#?F1V-ve>z|uT!uT%1yv~Auf}@RSPXQubYC% zvsy@%_`VV|8(G&IyHqE>xrAj+^~vh#_TC|kbQT)DkjP!x70#a>{5@9C-=DG7t0u-n zVUbQ(TQg=pmc5a|PX8ew;)kd|8o?GRg!bR;1 z-p7{u_CItIis3D1FpGUI;SrH<KR?C@X8=?@E&mjiANWG3RXi^e# ziGb7m2F?}^_No34;g7krFX2OjE!!2>~B0vW%JJS|>Q=IJKC z+cv*j?O}wQps^(^lr_4Bp?&)6EJgD4&okj;6FTdMcZz}yqnJLqTBqYsL&~%$Y`&R( z1U;Y1a`Lv3Y7EPsUj68JWNb-*!4Z5bFW%qZ<(@N3s96Bt&U1NeJ8%Q5sBLC|Gdaky zxluVfQvPpb@s!zOmX@NphIwI1+xF1@WmDpnL8kS!g~xCoqqJOLjUl#V`%mVb=2$0l zVss~69DjXx6fEE2^S9M397e8o%gI^0izL(>I{G1(QlH_xOiPRQFt;!a50}Bbl$i}x z>%}itc~H{f+QtoR%75BWqD9*w9Mm5|6H_m zF(P6zkb5$)<@(y6*w0D$oKSr&gWZ|MSNwEobMWS(9oYf4gwL7}q!}~|Q_#(obmDRr z#EFFv!GO|AcA+FEwPuZ&{;i2W!Ib5{yBFkCd!(m#xiBcfuu}c-*f7bjY>|^qm6uJ?DAoNz>rMv2TNt|Q zmzDGx!&Rg{plB*Jg>v+T#kT#@Yk%H-d#%gIO>^vZgGcRgX=T46GyT3Q^LgR4xXpEvL%HE?z;oE|V;!A95 z(nY2C(yUTPB0)LQUtqurb+xOUpX1(G^z@_wPOn>AYj{EIi|YfiwYHUh7MoT5GvRod z0ItUVb-GYP%hyy?+i~MN3n8S0yi!BAwL5DV7Cf1l3EN*F0Wjdr&V}>V4)$^-0vv3< zf6I(TxJBanPKElmU1RJ!FwAgmh1z_TclZ>aY+1u13rE)&>%4yvou|`hjIHvy#dwQn zN2oc<_sI_0?L2?72+N@3!+>&54picR#@)8$MOl*pE&1UEQBOg?(86)`2xmz{pWDA%fxKh0{$eY#tVaDyZ2fjDdj=8uqtO%J zy>n6K@?-z&i@%Is6FaA4Fep>JsM}oSp*(M6NUxLA)*{07jrLz2)jdpw+e*TpHbzHft7qNA8o$Zzi#Y;C=_ z_If$LAYh?uG3l2GoeR}%Q+0{F(&w4N#2&w(^uL_UL`?Pls?rylrz1zgnqGpy_*A~o zy+_MU1#SPj5%T(n+8-+3<{i!#f*!7Bd(aR9Ti2*LhLvT|UPm)kjQkm+Xo~7rVCgrj zYfq}KS@c)iGa+`^8woc~THdiQkQ6Il5|3R|5O(fuN$hEI4Jfy3?U-iMTTUf?aa_@4 znk;RZj~(2ho^<5t70h88+R#orcBu1*^7i}uHBNMYe${oav&HA*UElypO0ib+%4x;d zlxgU89U9ALf9cy(l9Gbod-YYe$BixLnTZZ5hn_0uXQ$}3?9T6N_)?guv8E+4tIWq_ zv_Wr#lCG{O#9+HoV(T~65d?wwQ?M`uLC9RI#Fdn&Muo{vSPLuRHnn=WW3nqf=Eu{D z3zScxJ^U=z68&RGFPDGr&s_OrO9)-K`e~9QrtXf^x92V8?rC8-j(w}vFVpx3Qwvwc zKkwEwyjpr~QOE2>2(E1Y<#sGV!zi)s=4dyjy`RLob_Jnzy72CiZ(%#X z)*IRbeYedFfzX@YPB4t-`kud5W=}g!w&d}+$7Gi0VVeg~cO5-n4M=@^m$gzQUl$#l z!0V(;`7*7^IZ$yHpi<-4DM|M6eoNQ9=*{-M+bNW`mjX3h#b2keO~J(1U)i26&NH9) zO@W-^G

    U0ha=JKK>__(vP8mjz9A@wE`h-RLFild}@u!X(pI;qRrhPTh~>^j-wT4 zv0Zmlb~hA78;u94V72?rEhO87{d}GOwPw6Z^?rWl$B1#PR2}5B=W+PgGn{3mwcd7l zdQ&03l0TUkAq}_)ElXj}ZG?8O>^qS|+YCEv>_@^C^e<`v-h| zOt757zS1$RKe@YijYCe}=lIwJ?6_$FahCb~o>cdEQk53-X14#X3QLlF zYL8o^WzL91Y5i(xee!@6#?j1Ji9uZG30Gl&-Gtz}VL5u>^m6q|MoA&>Qh3Ak;wJeC z_Y>P+>)k`ts?mPpTSTA9Un z4q7u%vo{Ox5Q2T{K$;5+fBvW2U_`Ej#;+$?4U*klETrFd*GC33uA!OsPIjj#n^s*> z+V!j_Y4R8~SzXUZPZ!~1N4p4bn8|7THse*9+L3X>W4SDeK4-#vg<-FJo4PvJ8|}@; zg_Ce=MNv@z)MqA^~E;9;BttGi5S}EJ;lZ&FA)&Mp^C8~uH zPr%pXxgFe==znbmU1*#7F1K@~w zJaDfad%hz+>ay4IE0v+dS(XdFRgG8GE1sk*hJopQxSfcZ-!(g~_YXca;etcubs_1F zt({*dm-pXZ6`U4(c&6qEx}n9^^4p6UzU}Aol-db+zruHbq{~tOgOh1PuMxE9B_*Y# zIYyzAfLCgx)60;(^_E?_1Xd$CJ3VrZx>8>`M0c*;bzFVf%d!5`r7RxmUe)`4j(eBhf0(d(Im!(hb>HRo?g@uzP2q9 zVXAfB``;}E`lX>z!y&mXU6x5}Tj~u8IzrEDY7b|sm%W_WMihfru{%}U%`l($wvC)# zjqs|dZ_}iDbV>P8t-*Byaqv0|Kj#`@5K#PqQ_`X^ms-I{o84i0Vj$SZHRazr!{jNq zl#vO?6&5C^n+hs+m!tv;^6cF`9j*SRQ#HU3Um|vfB~_; zjM|S&$f%?9!zK3(x=;s{h=m@w#&z!8vXm#nMvjgi)%|A$Z>|(|IzYhH)v&onn=~NZ zkNKiX>07?Sh5qA$M^9DA>7=>&#>?Q%7t2^u}onz?<{AR%lETQldgzQ z735-QuA^|f9xp=n=qf@uNSX+D$w|1@(#%5Ey4J+zwp({I0Oc|MsHCsTesx`Kj3(hi z9Fwg!d=z`0>b=IH+owiMz3lc{)11VJSqwcozcJL)dLEw9;7@QH7zh*Bz?Zl6M2CtK ztx&nPUkbyQRuU?l?n`&bT-z3C{#bs!geIgC7rFd6l(ubi3yG^lM5v5&TN1H}C z0k#MDGrINl=jMa=YE7&2nE|_S;mOUfsWEb$_pvzssyoD;$Yx0CXelqvPibb{(aQcd z7VPOp5!bF9=-i=>)y86hkX5FkdXP2yb&c+F;X%k^2t`o4R(@AKAdyvIB-HTpIe6?^XF=)i4LH1DPTdj2FL ze~(Lt{%%Lc*T*O`i~I=Fu%b*H|B;}Z=_C^i(e&rkqsAsw)h2KMX9LNcFX9Blf-N!5 zcGk}-EtcMmHHC&Yd#r=~P5xT+D#kfFgt!{4&=cP8>aiJj&vU)f8wUK4>yn$oLUx-F z8AnNt(<6p@?SEG8pbfMOD%rKCKebkJn-DVUV8eeFIi|u+vy6Ty*LR_8wV7OlH#nTn zPQ(eD+Ec&qdy97LxlE^{5t%-RWKc#f^YQRD z@!$T(ql`HCY-}er2BX|v9UJ*5lc@xi*z-X-cxs2F%#?imwYFeXDAswe{nZe=e*NI? z!%MTxk`g%r*dpM`xPp^DS*=Cqszh>QMdL``SN!XAAcYUlCUik2&tWQRSL(rO1tCfb!{CdWVfB9sJlO~UO>4+1PXX6Kh zxg;CdFrWV;1DS!nU@qAgn4pD#;;z~U^Rv^tZJp{hb8KjnAhJjoe`A)!(4E+D?KRrb zbva7 z0J8N>FXF669LA!REvLd~@P*lPYL!E%YwPMvs&_+D-^fl(e5)p!PmK?;mgSK;52)0h zvoCZ?Qbt@|i=GH68QMKFI1U@Q4uKk->3$Q4V`TZQ<{^saq|z|`&#@MB2`6=|SEsC*?j+_@*zTSJ9t4yC zA?CBc?-jMfpsVh9!)m{i0Zrc zUlUeY$cifIN%xkIOGRh4wT-kP*Dd#GfMe>Ig>!_;?`7TB6zu|{8KY`Uc^|aoHZxF& zEPW!WU|HM5pb%y=|0&;rE2q+Jd`3ND4JDMH-uiZ9ohDRzs8}bHQ{gpS!4VGInYzi- zUE$lCl6i8&ces}7Q3-+;emnx+&6Ev6wT=sAWWKL(CUkKl2gjyK^I6*5hQG1@rRkh^ zI;{q=p+!B+J4fMySaBXHSV6yF<4wlptccQ-ksjH(K-y6;fB!L1J127cSBPX^TWvYm z!CgZ*L6GEMfy8$$k@8wyc8{A6eO8Z1y(tr-c6JwqVRb2!5nW87-UVBC=dhf8H);i6 zjGJu&ita&IXhID!A=MVHiD94o80ZnT>a2&(u&JVWPk+DPlhgd}zP=?XkmWkRbZ~eEB&rzZn*3eunJ@8e!K0@A$Ed2aUTv>8kz+e?pbd1H=vhgefC&GgG@^?dN0^N+ z(9_vt^npxGlOxQ0EBBkuoqKxJ*~ExN&&?6nb{exrGa_nH>l#t{cDOFN)*#;*DjlZL zucGz^K9QFXt>VG*Y^l9uq`x43q;!$9CV4mu{!BZ)G&xv&?^O-qCyVVz0STleCi-@V zvK%J&-?|g9MHqJnnVo^cpQ#+x05+HHl_W5==b&}5&#ix=I)I<=Wrs~jb=@2*&&YIH zYa#|d7hN6wEGxfNLpqST@8fjr%#l3Z&d!9=^Qn_so35$y)&@p-OJ(->s6>Vs`P#*} zb5xE(d6u72RaZA|70=bE=x@DJzb6iF8jBz5ekCo!;hn}&S(fJD!$l{(T;|!Rpqp0U zvAxsbYFdWqq1Q$!gdkER7U|{9-jE009;LJG@-xk9(7z??-TCYzZUIZB4C5i1p2l9K z!FZO>lqzLq@MTK%8k*?#hNAj4jkow2T`5g{VqkHeeULWfFE@{z(R;MX zjko+{N11gNxZQk8WyeEB^w2w50Rj)O*itxge4tH5!FCsBIxR$nzuVk5V7bTN$EVT5 zM}D`b<(5n(y^gezqTfZ_?ybJ=9dAf<=gru-0lIVlR9)F($6AGNwxVaet+ebaM){{E*68c2ScUS%D{-C+L)j@t*YH~+DIxAUnsGz0sgaFd zLl~3xCjsG)iF~SlHGiHpUSC_Kspt)y%WJK$&DXRKJjP6>dUVE{r#DlyaEJL4R9pEu zRQ{1{7xuDUF#CZgfK4{`)!|pr{7YR7YaXSPurJxrua`ngA3}^Kc znPXu%&GgE&xx(4y;MA1K zI%4E!araPS$NuX8<+P(z@$YE$ZeiK#Cqj-e)6&wFTbdM$$I@fN!(+@?hs{v^BdobR zLNa3FUF9?17EWU8Y+8J=F>0c5XW`V3c`h-oOuuCtyH)&5r{?qPkl9e+szRNc)6}#5 z!BL;R$K%G8{f#t-WcaN+$)O1(th7=;O}?ExGg+|!yYh#UPs;;R+uN@+q`RX#|48B* zNO5%^XD9SGrhWpmm#{v*w-*5m13(t5mI_jibg_#dbipr5I&iZsv2k?JibOCR>Rm3J;Qd^<|A zoVY#2(k~%Y(f@!{_rZWUaipo7PdL%+{7HO-iO%Jy#rgAlQyMDazW&B zbkM#%<}>r9h1XGYFLeCcW>@9VjYUBbI_u|V=Ho6fd01K)+MW(l;Uf9mA;6Wcd)(JV z3uR=nm@j2Pj`rAx&i`(yDhZ?Z$nIMZZ&%++tG#cG>Eo6 zPu8r5+@G_JejD1hqiEVrNNl6K95>`!qskM-MbKHLjJ~hjC{Sl+3-Z6&{UBa3W8EU! z$QDb6epBy-lkF7uq?lcHZRDk0f2G^>3UIcy@ZsX9(u>(Nl;3rgFTt55>L$mZ4rdlJ z`SK^$R`B%&Ru3a$$AE2C*Zew$$2|YePgEy~v-|AteTSoV*z+UC(}b6EUM_v&chYX$ zG>mqBFB|*re8>|w125qw9wtvkoB+v;L8Y}JT~>B6V1HCdSCZZ2i6+#^RQs&D**R!G zlXh)$cBObGRShj{U(ML+?F71(IgU9cbl<(6C4m;r+VXPApw$J(FUc`x^tx`W7scY8 zu2tZ@+`Nx`Z%2T2#L@)GzLOY}FF|PniDl!N6%SkBX+n{r+gz#r&VErulHP*XSW}G= zw&9B_4?auC$nkIdOCu=}p>JOy4LPD^6TRbY>0(M01*pMqhC>y2m0ieb@=cw1vbMD$ zG#;%6wF%LBS~TRUxu<|x@stKfPRh>Lt{5REKx+Pol>wgVi}&D2-TkqRZq%a^p^Kp=9GV3)$wjDr=_6L zA-tJ1zhfv{lCmc<;hsQMeb-Pbbw4rNuz8k`PP%e)?(}3x1)ujlxZ~z+P3gh_I$zXaGro%%HM`7($vSGQDS9{U%2PGz%d-#dlhV8{a! z;FXe_!@`@E1$}}&kw6nPIkmr1`FU_%DDYkmhXuLN&JzDmGWleZS{Ln@&J_rI^q7{8 zM?8>_kl4sy2d~soLOXO5i%APz?q!Sn9MY|d2G-cuhVo@V5=hLAgn7KvtjoG@D)MyA zI($NPO_DSmM9O?3!+R`%Gprj~* zNGbvnqkDAMn92b}1e6{~NlDje*aYcr7&Yl0(vC6q-otaA=RME&_uI<{ci}GX_~-8* z_kByFa{5QNvqAoVGpXOt394q#ZEXTlmkM)mP>8T?*Zav`PTftUF1P{06Ddjo&u6~# zN1vLp3OwVW98sRfGyz}LKh({w_<(!&qP)kqs)IK_3P7dmuS2{mzw48{VUJD4qVDu0^sUZya;tu@mF%%ucA!X+(1q19Bm3+>t&CTb z&_fyeLtc3;X`l-48`fqG@C^0A0d0b6$cLZ`3Huzkmxcnai=lTN5}B=IW{Y2+>}08R`mQk?9bBleZ~zKZQCmj< z+r`HHXz*#|^r4A)C(zSeb_x{zEJv~*SHP>tDg13b6jT=pa&omE7U4E^-^gnw|TrwS(R-+Zh&coKV{mAcKAW8a0 z@y-Ww?@vn9RrXs&i)WF~Q>R zloFe-!|JZi;J4sOK8bLv03AL5y--L{a}qb8j(JS!SfTZVU6ja(gexI7ElTDyW|!7s zor#&?HlSzQ16?nCqgucTgl&>V71oW~FmG(JDB21axkolfM6LbQxD23+q}@xKyVNxB zsN-4phWh+WKkhdGX?VIlOqr(`j21;Sjf{r6jx6E({n8-8s`ewxUH-H%S+KQ(cUPC^ zMlbKufyBFmP+3z}Bs3)wdKSc*9s{}gHfNaDzZ6cCScj$psz1yXF;ux`xMnpe!e*^Y z)^~qY81;ep&h8_`#8B;A3tD)1a*h6Lu|y^HGbe=_{EITfqP>vk+DQ|t^4K3s>T-vW zgy>&lM1Mlzz5J%^|4!AX%bJhA8#zm(Qo(9Zrf2U;XXFR?($Chi{dGxr;u|tKj|kQ6 zff8eUg9k4+4=8WCvTO(=@FQ(*DaDIUQsVbB*ZF61o&)t*i+q_@w#==6K2oRU3C!P= z?QX^s-y3f37k)*7Lfcv5B&WA_8A>#Ab~UX@Ug9{0Tu=;-!&S;?Bl{c47Gb=E$g;k& zBO7yC@WI5yrjbYK>bUs9bj0Qr+W5s0eijI2cJbosy`IgZj>k0uzK<;Skoaqq2gz=P zkSbo4PrS|;UwM)%z@r)5;_9p)e1EGqWaIG7Z=N0+?TaLRyu>@kEILJQs#AuZFw;MJ z4WGAnr&<_w6ZAXe452&DTj_59)ZO|;+U?tuU{xBGmDFQsiGlg7Rv-2k*ZaANrwy}W9=C_7|vX$+eq@;(2jWnX;0=|;#c zbf4ILcY`v!Uq!)pQnSja3j$ONcqokU{n$xPJrROj1uDMJWK&`y#Rb%|wi zJymYg*b@NE&XTqCLfA+1DHWWJ!PczdHKu8M_mG+R;Jwhq=;kCE%>?cOH2iq;pDk`9HM>i2~)pj_N$eM61`K}Ooh5}G;DCZGI zo4wdRtj+>&LjAxJM<>@{YTs|$j+{BJhMH{ zvq;4ixB!Ou?k|-V&6cy(GjlBPGziBq67p4x`L6uZn(oqE{2vrC0>nuvE(KBjIK*Q2 zj~k+6zx8kA0v`14A$8~LSPFd_?Lfai}EzCNO5(u_+3ldru83FBusgI>Q`s>)S;~zDrI)+!^Tg_&WK}&)VSu~DJE>w|t7?2y)~9}UzSyD@ z@lp;xl=*upaC;EJc|C70e8Qtp+I}k|FRWFAy`ATh*E4xMg9f&3fPLj_&Wb{=>x*aj zmyjVe(*5V2z^fBL_m)<14!DJUn!OFsa3F9uF#|I?{r%4Nm#nT*z40eY>)XeCr&dAo zol&y|Fdp~9EzzM3L7bfG0uDsl#`)My9FA75dYiw*m)xfx#i#WiZNj@FUQA7b1bHr3 zAwU9610xy(1C$qR?3IBLxlFSaUCcQnI-v6zys$vCcTdY|?MX^k=;?8RNA}EM#(hI7 zS{CMo-+Z?M*pGxjFj)?U>(nG99%~BKS2?^RWTs6NLv~4!M?2@R@3J4YbfBIgD6uTNw~x;LXhetQTm#O(K366KZGMx5vOs zmwzib_QthjUd}1j413W32C25N+HpAl1PJhfcU2rPAFY&C36NtoDI|A?cjf78e?}&W z3fylQpVW1nQLhta_k9s=?qDN4Tuy9wJ9E5|Ni&oEZ)qisHI|j#*d@KP88KzG-`4O| zjk91*rI3=_z^CQP{v0W-`SEn2YI!mLqcjsnYC`rJx*@pT;Jd`ebSpl#pH_tI{BZCtsm8?{2Vv z(M8yyX={F|+LCclK-**&NSBUVB2gKKJyUD&#NWIsy*j53*_s8wQcX3?);2zf9OvCD z8ZUpSxxA{w;zd^oFM?xb?t7;$-+9wqAW@Z;*qjtwb|2T#8J0A$#vb_YU3H||GX8iL z0k237CjJkX;o`HqT`RFU^-SE#>c>nH^ zCq!BETO?whnlgme-83kO`LOvppy@C3OD8#pbe>qf1+a5{*tuGRdX4i z3v@_c<@XGG-spXz1ov>_R?dr=-uF4;uf8O;e$^^_R?u#n-`%O9l$aIsLbaolTdt9o zeKt!iM}PzF;%xTg^YV9IgDPB=&qnDvvSArTsluc}-q6R&7+qkuUu9#gYjBJS1Sb~K z{8!u47p9y5;%)Yf5|z0=j{So<=E?b}R|$4D-#ko!c>A2)pW;jK&ZK4|mbOZ|$cKk` zI)LsJkA#$C*0OXHBG6KoFOl51!to2OJ$uQQiHqiKmP4NeT0d|nek(luoTNEG;0_Y_ zg$H?flm#c5=L&V&CX!?O4J7Q;WJ{Z7?>;N(sCzAN5;6IjCTh#bVZA9m-#g>t>dsx1=A&kLu=sIg`&NkXs%t=liU(aP_3g`5J#GvXad{gi&7d+L~gnipHM7`^S+&E zyIsN+N4}*8oiI=wvKFINrRAWOPUT?f4PX<%P0tOpGD+FDmb*@TB?mi?SoyiGeg5-yU6tkpkC)3y_6@3u9B@9p7u%H`rDPY0S-Cy3 zM4XiMy*EJa$EL^+tBQSf&s~i@626UmE8T|H(1gYt2z^pHx~Gv2%6JbGy>C1-bWxB< z$sxXx80!j6X?S-3)$-X#%gW7v%8fI8Sd2&dyIfG5-VqSSP z78Y?9APNAoS`KyMreKzm5YOoJ@fFocl&s zJa)_SkS2Awk#b8+IMtgO{dxZnonMA6GWLBh*%J-FAy)+66)89IvVJewRFp!Ny(3U) z(|{Z9ZY^zii9U#`HQ{2w1mGMN*(HF(ra*e+QVyK(n6x@$G4jOVsO%2h>DBkzwuPD69@TRL&dZW-}8f#<%tQN)B{Ob)YmG>E_ z<;H3Lz*&jKZb0bh1#@eK?|Z#=XE0`aha`{rN8(YEOGfHEllg(%5M@Os^fKbm2XP%Z zTu{a_l>0KUqQXwURDAnjdjxS6z%2@!j!9la-;K{ylwZ~;Ssha+KPNJMMSgRA(7rD2 zwvZ;aw=1ZMOcSgSHtt81jmCoWvr8LQnw?xST>D*`Y$Nf~NlSn8uy+N`yt?-#dG1Kz zt@S-w?LcqIm6C<#0Qck7yBV_SsN?l3F%Ik4GP4^4k7~xy(6&b+g#2e6D%S6tlQ`%E zkb0FiJ5E&_g-HD``%l?s#9L_BC2_%U~^=vGy8d)7rZER>LTw-W|=l?!gtO^*-+rPKrz1A4Le;8%m z6?(n@MdpU4e&Zig6TLC)c7#iNH^3$jd~i`E+4&aU@Un21<+NZ|KaPK<$qV^QK3D97 zheX}`CQ>0pP`GRGnCY`zk*x2Ede3nMh)yZqb|Br|`}}_I>>V0;Gt|~EO7-rxlgNy; zag^p%5E!4G?S3D2_hlz1`p$Bw$o&);oIR3slh!uyh@Q-=g~FjHxneEVJJ@KQ!2mX6 zqvMSz$XLC40iSPvm0`Ma890I{(bK!M$wq3zJ|!wTgt~l!(C$`{kBt}|=}xXqoJ4v*mZ*f{+vs0qz7KLt zCHH|-tlaoH-A;wUPh2n18d}k8?euQvO6Xd+)|p5?Rc3 z>)te(c-v{xI1=Ns$<5j+tT4z`mt~`QzqG=>uok}P3ZGc&X3BG>%23>?K8E~59*)wF zVq(bXTjRCCYD?fgl55dth!Bp9wUTr_+X_T?&dQqtg_HOI2oaxvFxlRzAUE#!Ud*>g zdiyi>@Dc85R901Mvd*jWBE`wOv>3dUb7(3sX?HsEQB82kB$JG&d-oGmZ#Vmd4VdU> z^WFz4V93FxXrV_1p(gGasF|!mf1&m@Zx2L!M~z5;M4XAsZpCJ73RwIJUC7L%8hJX; zpFC~sq$Q!)XOmR)zW!~;J5z0Fhq3gIShbkO{yAPZFuGl2@P+l}Y1zNjmY4H3^?IZN ziodXYzOPC-Kp??=@(+^Ni1!6-@8z+HdU;yRNk5hk>I6)ohQ32x-` zFYjCJo{^Mq<&?3`6nk0Kj5cQYpe!PFY`PV`jyg64C=2l5be+!(JQ`=NS<`h6R2Ik{ z9la4MHNtLa75g-NiWA>+n~y=Sw?4Yjit2f}n}>{}D|!B}s_atUu{CTU^Xhp~JnrCK z6-0Y|R(TJ7dHGOR&jWi!!?xilhIHo>1||mb%Aoi^d)r(?F9b7};X-udE@?xHVtjJp z=}ux2$E$hX^9@Is_s%nkKD~2qOd)-Ja`zSazYxO{=W_H>Tj7PmbuoYna+u*R+c;1< zdVP)U=@YB72Lz18qP7;?(Tu5jAPwHO6JLRrY7pWDY{{42fb72zA;|N@ZoS~gYXdr@dxfFe5QWJqq;E7a+kA$;}`f3$VZ*Y}OM?2JTo9*!0!Piu1Z2gDYgu^h#A zbN?cfb?Rqn$m?@w)5ucw^R&e>>>5y-;cx0kjnZ)gb{2gXs0tmG|9h)Z-k z?RVVFj~}ACKoqDx)9m++@z{40Rf421hd3sDef0x+6VlPx+a6}O7)_(|h|x#-rCTbb zC#QHLx+L4j%p32dooUO*P1D={2s74hN$u*;(_XwG#0OZQA;sH*(r(YhMK3cS)EKPY zq;j1sJj4}~q}EAh=&jW>($QccM2$3_kwsd$=4VGDZOC-pUo{IP@=Xpz@=Jb09&cz# zl6Bfeh#`LY5h8a$;$=H0P3yXBEJK%V!~Byf>I9v_8v6L`k&GrTKG%KS+UA7WlL6tS z1!U7MDCSMMg%z-f^b%3hTtb%3Jd!{j2@Q-rYf;7}ZLr+(P1fnEGddHF(Xl=uQfDGu zv!mP`qIDMm@ykBFqzgMj*q)JL99E53W)@kE zbS-W>`qp{$svjCwHq)V^`zm213mu$A6H zk+tn$#zHntMmV=1*>vdh%*TBrRyxlsHog+5YzkMX*53kfISQ%J)bB0}-2B4n5pOlV zXBVvLA=3#q^v#w9dhUQ}N9;Gapf`qu{>qskno1!W=sk^Vt=)?Llo=aUvdDQ&NLP`^$KXX~d&QRvkEK%qFtg!) zrbi|iutZjFFS{TW(H(mwa+!{F1LXF<0j5w8`99~XKCq}QOkHkKrUg+?~@zkQ zfHCrkUYX~l$Jj2ftekYkFll9}{=wB2E?T8B`d^+P*V`D1PK2j^;_?xwGmwm3lgL{NWNx z5r=hLu8)Vyj-=t*+jfrx&#Q(2dE`3S7Axf<%VclLow4vF8p&5ZMZLl;(U&R~9|E_6 zb$DJX9@n06^gSJ$S2RisnV%YXtBLvkfvM~EMD&Acb=$*Biak@(QK|;P!_bG`ZxU2Rvh_+T(<^IQ%<1D-Uw6>7uD#Z$ zob&spLJZ`=Ae{*!_A;VjL%0|}BviwZ^V5Ou6cg{A-HFG={;Qm@w@zs~ER%ujRiF0A!G(7! zS{4p+${^NsaN@3W-m#3p8C@m=Gb=;-rCevb2RLC1NJfOi#~ov?KntzgUg;9@dMSm% z`FMuTh5E_hQ7w<;u%Du>d=hU(@0x~9@u@{Z|{9E>@I!kpRRt%qI4l2!_Yt3D2vR#1y@-B^*`^e98 z+Ku?tEN)4pQF; zHN|OaVpUxob4M`TvBgRyT5V}AtnY%!FvD1r;e$O6#Su-(B4M9CGtpgo`7jG%gi2jg zr@>3>s$Mr@F$@frQn2LEi~XIxI6*ca$m#%w4{Nbre}#ms4fd!K7{}HI)TOZ&{gK!1oQsLjAFI0sf^>kfVM%ufmVCw(c9v*Ys`~c>*jP=ib?cD^fkf> zvW&U}9GxMOD@K3s|6#6@zTj5T+=x~lw3H#HB)d!byvpEcLf2YAbnFf^97kz&MK(XF zHO=pZ8p=!9V~XmU6eGqASIbcC>%vz71EQq+1qc-w5HN?V+`5xd(H!>z5e1T;Rq8$j zr}nALMOY%1+jZ?paT51xdJ8O#dWih!;;n=Px4&+?a0)T%UX2k;&{1fjrQBljIkPv> z(+DiHe%k+x8cg&cs`p=b9>bSVE+}-j`XT-9{hxuR<1&mk#G#Ko@7y_y*Jx-ia0(b7 zTFC_kM#R1{g1_pp19#u1u#&j~IFA&6NH&66+F^d$g&|56SyQczyxQtXrH6v57psf!P3u%By$s z={cM$@Bhagf&OK%n;a0oKhG3U&>8N~jd>M~{XW=xu_f?vcE$o@2(oBZWmEZxS2!k&MXstJS!rsEchV80vdX( zMU}mhHvamrK*-+QD2U}MZ3{;%)7_gw2^qHh+V!59dUIHmzRI2`^AY0WDCotSdvbgF zhtZZ~B!t#!b2ZFv9B}PIM#8^I>?8&5XXl5oWEXacvQ$gqZ88J#FFN^sLiLa|Un^bR z)T)0{Xh3acrIHWd#fr7<4&3T+KULigVsevcBYr#!48kIdjMR4~?!1ew@&-3J-p%9x zHvyWO2-^wz-h%BV#+Y&j555Qf*~SHQ;Pd;PN@-n_)p}2OQnURZZgsu5^v)#iiL|NV zUITq`NjIt7Q!dL(qLu<-JIxEIW%8GCjDX0al|5@-4fAa2|5lA?#Dp?a8eg8q6<2#p zn>fPB`tJ1z)!<4Mhr5v?NG?wjBJw2Cc4dQZbMZpW>SVC;k*?N-mHarBx(?;!V8COx z8N^|LGNAn3lmFhqJV8A6ATV7i?GChGp=JP2BXqAbpiO^;gC9cYw3#rod}ywm|KOC% z=9A3!J!+3o1#cG-SY{<}S(K)Kb<+LOXkEtL#y2(1S>Ri6`iCO^^zqpTpn{agu6L#S zEywD_ZrW2m4u~eRvO&Y3`hGL3vcctDb_oySSKj+2VcGY>A5`_?$fAvKeKI)D*X!mt zyK*&`a`%T=L6}a;D!W5?KOMJ$WOaBO5ONFIe@jla(Y%z58x4nHy4jlo0cuV)Q|GU- zGwNG+sa#=mT>FlYqOGK4;VLd~EFxFnO3xgcYb1+~(d^ ziKAn(Q=3~nC6sk(*RS?C1Y4dfG_~pCj$0MD8iJvV;Nsx3)irGAl4-Pg6nbPtR3D_J zjNl5l%LNLc0?w`f%fy&(^Xea|BWs7_gKbyDGXr%It(gTO-k%aX*jCWEY-fqNTf;eQpxjh+ql16Hu1BR>gf9y5H!F#PZ?#@<| z(wpV?ZTdD;k>71q7GCK65#ndNSmIWXPYV~RTDa2L!Vqe?Wq9^6TJ!*aa$KvOi}Y?N z0C5XMp=K82U-;Pq{p=(oN#`Pg16;LR5Ky^~2|S30)f z!_;VX4tFA(nR8di0zSoF*+PlK!Lk3g=>3i3Dife8Ke)j zwS`8A1?`#tz_7_QqAC6E89P*xzlr@ zseW)Zj+tF-&uGO>)49ekn15=O`r zjncKji6L~@XV^Ydnxm=-(cp~#4&X#bl77lR2IB9zX4c+*I_u?#(3kme823<&E~s*p z`V%9;jeaQ;5RX7JUvKVS-+F2Q>R0cJek})|Hi#_@I|}?#MuqKri zgU_xsLo;stQCQfIlH(kF;k{F#KiR$e18(s6!sxg!UVekjy^B2D&3hyXv3A6-O6|xZ zF88s~p^lbB!E=MXtV{k(tnCyOmtT>40sisne{;;&&~l4pm74FqFSuf=U!5AIgi2nX zjs@srqi~_6soLiiwcYMnhR`G00Vs=XTJ1+qZ`+`bBV-O`72HlZ!Iv`kQpC$nE^O*I zwAK*UO~L^9o^k+)-Fo0ZV&|`wMXr_PuClh$bgY%Eu$r@hh+11jN;1E=klKu`&|ajZ z1H{?il?%&Gh3=SKUheC8c3`AQ3Aad2ysB)_mSgLXp}%s{Vh?khZLi*R96YP>Q15>Q z;{cY3ZqnBzK7A!Zc*v7b9kvYi0f4^MD1s!D2YUe~>$LQtwjxNqFACBwfuct*R!BqRlb9|ym z8=lpU56IN{zeEkpHP&LrUxvvD9Wsv$l>8~ieLxj1Sih&v{4B{L74{rl55Q{KAA@uC zqouWl41<^LMR$2bGzp41HIMPtNv_i;v>aB&KPx3^#@NLo({p4<`Xe`mK|8W6SonWD z6Ink~h*5zrHlkM}hosu!GpPkn&tE|`ozgq}1r_uPK3#24qLJ&RzvK;9<02_?jgGyQ z_j3Ynug>^uF4)fHNL*rT#;+?{A@Qvl{K+x6{Jq9E>ACm8MS*{xL<3U%w=~0m4^I}> zj!v}A(lx43U3kG}{5Bsd9DDHYv^%tn0fSg|<}ZQL3}0c|QeT1K4wQ#zTw$U~Z6u!D zsti?TU(t_K&v!F_prk(m6m}K_H2>ztt%Vnb(FC#1yqtuWRnXXmY`5do#1%a=z0tC{ z^QRNXyO25UC~9+e9ri?Y08cq3G=@R9ofCT8=Tz$XF9Lo4a=#~$6Np?k1b;4d_ zy`Hk4Lm4-thQ}(K9xJ**3UNg}z~}IMgMT>bZr7|K|(!7_;e({4UtV^yxJWd0S)DN%DEc0c*!cxK~6 zKi*vQ8|7}%%It+> zJ8Y{q+$n&HQn8Q4j9cy)7E^JdJ3Oh)wqWdIeAwYI(qns}=_^Ot=lPG~wL#3^QmU0(r2_y{Xr}pbZ{^ zW;OltQFrc9jj4kwsP&HVFWZ%RhDpqi#b0g3c4j_@lU=sim{I1o<>uYw4O~y5$Se)$ zXa2`b0jHE04Agy4%lejA#zc8%Z6Q(S%IL5s+>eQ2qN`r$lUYXUpmzYOzSH3Tz~b3S zv0bxsdU3asx^>r?Zc}p=7JVy7m^4`Q;Q6z>nV>)lQ%Prq|H%gjjVJz{-HAgs=ZGx9 zZyI*VH{$=T;h+hW9MC0s5yjYhjoUzVijqNHZ8Rn{SjRA3sJ zpS=}Z!9@rjKoE5!Cgp>5ow^6l6gta5abZ-X7#cqo^*gYS~)J&UAw9Q_e)T_weAcL$n1;YadZA+yC=q%&|)Pn-k4x^2km zy`WM*;eC=|X<=ND1Uau_wC&{{RMO!$&i4xR_6^?gTi6|#8ax^nZwTF_YVm6X?_>O9 z^nN2Q3UKv|j`giFOgb-g`>*QMwenIAqn#S%{a@q^?-db4jsZQWh&O~~qsKuwQUvnS zWPK}?zO=)@)FOe?$H{K$F6^ctp{5|kCn*0A7is$}0_)co9MsTOmP%Od$}5!bIe>X! zqsWH)ETB2k0j?x%Tf*`?k^PS#q{X#z_MDp#?QnHc$x-uGH?Az@=nbRoHzhUv6)yf+ zZ!cn59ZGi!YT?6OpLKyy-uqi8Y2{xI+hZMtVh@TG*>aX;OQAs%9yiaNt0qq8Q$^-sixql0aPo=8EBAfA!mbL<3TbCT%|{kNPJ#{ z+N(8_Xir_&2Q~6d_k`toNQYY3X_Wj{nMF*_^qY?F5q2LyFksyAq%4mGz5Lxjn;}+3 z&&=D+E*S%7uVCT2;4Ve#Edn{`m%QCQ8&Wmfy+;Lf{1;k%^Ubn`xA2cg@)HX}xzEp` zd-#u6HE^7ZqlhmZ>gYG3`7NKwV8_LNTpO)vLf4L^o998ooL@-L00-IcPUMK(0Y%2Z z(=0GypZs(s=+w^wXm#=(hjYVg2(yqTld)G!dmI9|26`H`lD z@?1@VmsUpooY%R$9tf`)s==&yEIGPY{o=1-Kox#>6|!;g;Ue-`ze){9K7t38(8;Dq zGmu@z>jitastXC$p5i;70)Er2cZQmV54P-j)mE^yV|DAaKIwA1LZ@$~epu+;?MWOp zPS?-*juy_Pg6>V8T4wCvveKk_62HT0ooA0sX@H67hf7Z*A9o1s2u@rt)gD@fR{HdH z2F^3${`B>fg-=h(C$)V_oqI5ln*Y%{pr%l;^sg1M&klMQZP#&ZM4xhb@>&yq=d%A+ zEi3n+z;5{SrGZsWA8i;q0=sC>A$&nCCQCjEy@K-(6^|s=#N1g6bK7exYv?3^I(v6% z)v)BmSR!Woy53EjH&p9MnMmF;eHN5~eeUW}lm3&~J;gDmxV>s9D3z`OM_DtWGj z8N#l_f%h|o_K+@EF@ipzpecer0r@mjj#wd0s?N02^VRf=%BLY)z0BimGl-;)agDCZ zW*ADu)y&3!LNqSB6bD;9+7MnBu`cY`KTSWrnz|lh=`M5EvgHau1x9ZK&ehe9nuX}= z-Wg#B4IBq-CQI+;#*ry5(3!WNf-rwPANVdN|1&r2JZBH$oSi>nN|4PEJ5QrH2FHPnVaA48t_AFIV@&1sNQZSwc(r;2?>a57G}e_*n&kCr#Lf?31i zBCFk6@8G!c@N9vB$nG=0Or;P`y_?w{xNtUCK|!qe5T?p*`ZNatGowzUZ{@)p6cmGU zy?uVlyrj%xdy8vCSYLbzCn4_tbBy)?j~D@~{GH%GCOIvzc~&yvQ+*=?68Flc9@o`( z`UM4dmK0Bty8eK<)t0)9^SHQb+ad+{ks&1-eoyNgO9sc{tV7e4;-0#u5-Lt&!f?fh z)P#4BxPT!i%NxiL{q003Uhq_`9je!lJ=En`u2Q~P!aX;ZN{zjQ{#umaWK`go3W

  • GU}j&>%%hONsklb z{nNdXpn&cc6lAOHwWohyHE*djtt;WpKK((It>rsE_Qq)m5Wf*d$(Z@!ana-$Tarf> zpDvi^Ry#ATs|fA9GlbiA+^uni8;>T0+l^P2`|01*WA@8|TrDO?AUOrBA?YD3jN1ci6FdOC`I9KIY z$b<7-^j6N?2uPLx1MldYBG?=+SNh{ojhC~L_7>Ws$@V#>Y&5;eUe~stT`{X?Qwd+J zM3^WWNv{(cn0joE_2|!OAQ|(K$#+!n7#=jXNWRLJ?0~>O>Uxyuq5Z?K(+czm%olZq z@q&T(yo0$IQv)D7!9dyYPOk{&_)k}(P2gbozLV|QEqrbcBPps{L_LnHQ# zVlsPb1C=$+lfH&J( Date: Fri, 20 Sep 2024 20:34:35 +0200 Subject: [PATCH 65/85] increase nugget size in grid --- wannadb_ui/visualizations.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 8ecff43b..1832a4fa 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -40,7 +40,8 @@ PURPLE = pg.mkColor('purple') ACC_PURPLE = pg.mkColor(120, 94, 240) EMBEDDING_ANNOTATION_FONT = QFont('Helvetica', 10) -DEFAULT_NUGGET_SIZE = 7 +DEFAULT_NUGGET_SIZE = 10 +HIGHLIGHT_SIZE = 17 app = QApplication([]) screen = app.primaryScreen() @@ -290,7 +291,7 @@ def _determine_update_values(self, previously_selected_nugget, newly_selected_nu reset_size = DEFAULT_NUGGET_SIZE elif previously_selected_nugget == self._best_guess: reset_color = WHITE - reset_size = 15 + reset_size = HIGHLIGHT_SIZE else: reset_color = self._determine_nuggets_color(previously_selected_nugget) reset_size = DEFAULT_NUGGET_SIZE @@ -336,7 +337,7 @@ def _add_other_best_guess(self, other_best_guess): self.add_item_to_grid( nugget_to_display_context=(other_best_guess, ACC_YELLOW if self._accessible_color_palette else YELLOW), annotation_text=build_nuggets_annotation_text(other_best_guess), - size=15) + size=HIGHLIGHT_SIZE) def _update_legend(self): def map_to_correct_color(accessible_color): From 6b32d1eda16bc962a078040ad44e422493803c38 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Fri, 20 Sep 2024 20:45:48 +0200 Subject: [PATCH 66/85] fix wrong colors in legend --- wannadb_ui/visualizations.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 1832a4fa..7873ba0d 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -415,10 +415,10 @@ class EmbeddingVisualizerWidget(EmbeddingVisualizer, QWidget): def __init__(self): colors_with_meanings = [ (AccessibleColor(WHITE, WHITE), 'Below threshold'), - (AccessibleColor(ACC_RED, RED), 'Above threshold'), - (AccessibleColor(ACC_BLUE, BLUE), 'Documents best match'), - (AccessibleColor(ACC_YELLOW, YELLOW), 'Other documents best matches'), - (AccessibleColor(ACC_PURPLE, PURPLE), 'Could not determine correct color') + (AccessibleColor(RED, ACC_RED), 'Above threshold'), + (AccessibleColor(BLUE, ACC_BLUE), 'Documents best match'), + (AccessibleColor(YELLOW, ACC_YELLOW), 'Other documents best matches'), + (AccessibleColor(PURPLE, ACC_PURPLE), 'Could not determine correct color') ] EmbeddingVisualizer.__init__(self, EmbeddingVisualizerLegend(), colors_with_meanings) QWidget.__init__(self) From b6b940a30174e9298cbe847fdebf1dae19bcc284 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Fri, 20 Sep 2024 21:47:29 +0200 Subject: [PATCH 67/85] fix custom-match workflow --- wannadb/matching/matching.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 094b7a54..b0cf19da 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -413,7 +413,7 @@ def run_nugget_pipeline(nuggets): if distances_based_on_label or new_distance < nugget[CachedDistanceSignal]: nugget[CachedDistanceSignal] = new_distance - previous_best_match: InformationNugget = document.nuggets[document[CachedDistanceSignal]] + previous_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] for ix, nugget in enumerate(document.nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: @@ -474,6 +474,9 @@ def run_nugget_pipeline(nuggets): nugget.document[CurrentMatchIndexSignal] = nugget.document.nuggets.index(nugget) docs_with_added_nuggets[nugget.document] = distance_difference logger.info(f"Found nugget better than current best guess for document {nugget.document.name} with distance difference {distance_difference}.") + old_distances[nugget] = nugget[CachedDistanceSignal] + + old_distances[confirmed_nugget] = confirmed_nugget[CachedDistanceSignal] elif feedback_result["message"] == "is-match": statistics[attribute.name]["num_confirmed_match"] += 1 From 25c2c73bafea3a402195e6ad44f6e813dfb887c2 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Tue, 24 Sep 2024 17:49:01 +0200 Subject: [PATCH 68/85] display similarity instead of distance in nugget list of document view --- wannadb_ui/interactive_matching.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index c56f3c72..15a4500b 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -753,11 +753,11 @@ def __init__(self, suggestion_list_widget, visualizations_level): self.text_label.setFont(CODE_FONT_BOLD) self.layout.addWidget(self.text_label, 0, 0) - self.distance_label = QLabel() - self.distance_label.setFont(CODE_FONT) - self.layout.addWidget(self.distance_label), 0, 1 + self.certainty_label = QLabel() + self.certainty_label.setFont(CODE_FONT) + self.layout.addWidget(self.certainty_label), 0, 1 if not self.visualizations: - self.distance_label.hide() + self.certainty_label.hide() def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: self.suggestion_list_widget.interactive_matching_widget.document_widget.current_nugget = self.nugget @@ -767,9 +767,9 @@ def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: def update_item(self, item, params=None): self.nugget = item sanitized_text = self.nugget.text.replace("\n", " ") - distance = np.round(self.nugget[CachedDistanceSignal], 3) + certainty_value = np.round(1 - self.nugget[CachedDistanceSignal], 3) self.text_label.setText(sanitized_text) - self.distance_label.setText(str(distance)) + self.certainty_label.setText(str(certainty_value)) if self.nugget == params: self.setStyleSheet(f"background-color: {YELLOW}") @@ -787,9 +787,9 @@ def disable_input(self): def _adapt_to_visualizations_level(self, visualizations_level): if visualizations_level != AvailableVisualizationsLevel.LEVEL_2: - self.distance_label.hide() + self.certainty_label.hide() else: - self.distance_label.show() + self.certainty_label.show() class CustomSelectionItemWidget(QWidget): From a38f3787d803ba235d90520e5291b4bc3ae9fbf1 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Tue, 24 Sep 2024 17:50:17 +0200 Subject: [PATCH 69/85] improve var name --- wannadb_ui/visualizations.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 7873ba0d..d6d9d698 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -582,17 +582,17 @@ def plot_bar_chart(self): ax = fig.add_subplot(111) texts, distances = zip(*self.data) - rounded_distances = np.round(np.ones(len(distances)) - distances, 3) + rounded_certainties = np.round(np.ones(len(distances)) - distances, 3) x_positions = [0] - for i, y_val in enumerate(rounded_distances): + for i, y_val in enumerate(rounded_certainties): if i == 0: continue - if rounded_distances[i - 1] != y_val: + if rounded_certainties[i - 1] != y_val: x_positions.append(x_positions[i - 1] + 2) else: x_positions.append(x_positions[i - 1] + 1) - self.bar = ax.bar(x_positions, rounded_distances, alpha=0.75, picker=True, color=get_colors(distances)) + self.bar = ax.bar(x_positions, rounded_certainties, alpha=0.75, picker=True, color=get_colors(distances)) ax.set_xticks([]) ax.set_ylabel('Certainty', fontsize=15) ax.set_xlabel('Information Nuggets', fontsize=15) @@ -644,7 +644,7 @@ def plot_bar_chart(self): self.bar_chart_canvas.mpl_connect('pick_event', self.on_pick) self.texts = texts - self.distances = rounded_distances + self.distances = rounded_certainties info_list = [ """ From ca0cd8e198d15b44e71544784904fadc1d114c12 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Tue, 24 Sep 2024 19:49:11 +0200 Subject: [PATCH 70/85] add documentation for PointLegend class --- wannadb_ui/visualizations.py | 62 +++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index d6d9d698..7181e1c7 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1,7 +1,20 @@ -import itertools +""" +This class provides several classes related to visualization widgets. + 1. PointLegend + Label serving as a legend for a dyed point. + 2. EmbeddingVisualizerLegend + Widget serving as a legend for the EmbeddingVisualizer. + 3. EmbeddingVisualizer + Provides logic for handling a grid displaying dimension reduced nuggets. + 4. EmbeddingVisualizerWindow + Realizes an EmbeddingVisualizer in a separate window. + 5. EmbeddingVisualizerWidget + Realizes an EmbeddingVisualizer in a widget. + 6. BarChartVisualizerWidget + Widget realizing a bar chart displaying nuggets with their certainty with which they match an attribute. +""" + import logging -import math -from operator import itemgetter from typing import List, Dict, Tuple, Union import numpy as np @@ -10,7 +23,7 @@ from PyQt6.QtCore import Qt, QPoint from PyQt6.QtGui import QFont, QColor, QPixmap, QPainter from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QMainWindow, QHBoxLayout, QFrame, QScrollArea, \ - QApplication, QLabel, QDialog + QApplication, QLabel from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar @@ -28,6 +41,7 @@ from wannadb_ui.study import Tracker, track_button_click logger: logging.Logger = logging.getLogger(__name__) + RED = pg.mkColor('red') ACC_RED = pg.mkColor(220, 38, 127) BLUE = pg.mkColor('blue') @@ -50,39 +64,56 @@ WINDOW_HEIGHT = int(screen_geometry.height() * 0.7) -def get_colors(distances, color_start='green', color_end='red'): +def _get_colors(distances, color_start='green', color_end='red'): cmap = LinearSegmentedColormap.from_list("CustomMap", [color_start, color_end]) norm = plt.Normalize(min(distances), max(distances)) colors = [cmap(norm(value)) for value in distances] return colors -def build_nuggets_annotation_text(nugget) -> str: +def _build_nuggets_annotation_text(nugget) -> str: return f"{nugget.text}: {round(nugget[CachedDistanceSignal], 3)}" -def create_sanitized_text(nugget): +def _create_sanitized_text(nugget): return nugget.text.replace("\n", " ") class PointLegend(QLabel): + """ + Class realizing a legend for a dyed point by displaying a dyed point next to the meaning of this point within a label. + + In the application, this class is employed to create a legend for the 3D-Grids. + The 3D-Grid contains points with different colors. Each color is explained using a label created by this class. + """ def __init__(self, point_meaning: str, point_color: QColor): + """ + Parameters + ---------- + point_meaning : str + the meaning of points with the given color + point_color: QColor + the color of the points whose meaning is explained by this label + """ + super().__init__() + # Set fixed sizes self._height = 30 self._width = 300 self._circle_diameter = 10 - self._point_meaning = point_meaning - self._point_color = point_color - + # Init pixmap on which all contents will be painted self._pixmap = QPixmap(self._width, self._height) self._pixmap.fill(Qt.GlobalColor.transparent) + # Init painter used to paint on pixmap self._painter = QPainter(self._pixmap) + # Init point displayed on pixmap serving as a reference to which points the meaning refers to circle_center = QPoint(self._circle_diameter, round(self._height / 2)) + # Paint point and text on pixmap self._painter.setPen(Qt.PenStyle.NoPen) self._painter.setBrush(point_color) self._painter.drawEllipse(circle_center, self._circle_diameter, self._circle_diameter) @@ -91,10 +122,11 @@ def __init__(self, point_meaning: str, point_color: QColor): text_height = self._painter.fontMetrics().height() self._painter.drawText(circle_center.x() + self._circle_diameter + 5, circle_center.y() + round(text_height / 4), - f': {self._point_meaning}') + f': {point_meaning}') self._painter.end() + # Add pixmap to label represented by this instance self.setPixmap(self._pixmap) @@ -243,7 +275,7 @@ def display_nugget_embeddings(self, nuggets): nugget_to_display_context = (nugget, self._determine_nuggets_color(nugget)) self.add_item_to_grid(nugget_to_display_context=nugget_to_display_context, - annotation_text=build_nuggets_annotation_text(nugget)) + annotation_text=_build_nuggets_annotation_text(nugget)) def display_attribute_embedding(self, attribute): self.add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self._accessible_color_palette else RED), @@ -336,7 +368,7 @@ def _highlight_nugget(self, nugget_to_highlight, new_color, new_size): def _add_other_best_guess(self, other_best_guess): self.add_item_to_grid( nugget_to_display_context=(other_best_guess, ACC_YELLOW if self._accessible_color_palette else YELLOW), - annotation_text=build_nuggets_annotation_text(other_best_guess), + annotation_text=_build_nuggets_annotation_text(other_best_guess), size=HIGHLIGHT_SIZE) def _update_legend(self): @@ -555,7 +587,7 @@ def __init__(self, parent=None): def update_data(self, nuggets): self.reset() - self.data = [(create_sanitized_text(nugget), + self.data = [(_create_sanitized_text(nugget), np.round(nugget[CachedDistanceSignal], 3)) for nugget in nuggets] @@ -592,7 +624,7 @@ def plot_bar_chart(self): else: x_positions.append(x_positions[i - 1] + 1) - self.bar = ax.bar(x_positions, rounded_certainties, alpha=0.75, picker=True, color=get_colors(distances)) + self.bar = ax.bar(x_positions, rounded_certainties, alpha=0.75, picker=True, color=_get_colors(distances)) ax.set_xticks([]) ax.set_ylabel('Certainty', fontsize=15) ax.set_xlabel('Information Nuggets', fontsize=15) From 47824dde81a41de697486318d879c8f1ab3b3f5b Mon Sep 17 00:00:00 2001 From: eneapane Date: Thu, 26 Sep 2024 12:25:03 +0200 Subject: [PATCH 71/85] Add comments for bar chart --- wannadb_ui/visualizations.py | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 7181e1c7..f96bedad 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -572,7 +572,16 @@ def _handle_remove_other_best_guesses_clicked(self): class BarChartVisualizerWidget(QWidget): + """ + A QWidget-based class that provides a UI widget for visualizing cosine values in a bar chart. + It allows users to update the data, display a bar chart with certainty values, and interact with + the chart (e.g., displaying annotations on click). + """ def __init__(self, parent=None): + """ + Initializes the BarChartVisualizerWidget, sets up the layout and button, + and prepares attributes to store data, the chart window, and interactive state. + """ super(BarChartVisualizerWidget, self).__init__(parent) self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) @@ -585,6 +594,12 @@ def __init__(self, parent=None): self.bar = None def update_data(self, nuggets): + """ + Updates the widget's data based on the provided nuggets. Resets any previous state + and processes the nuggets to extract text and cosine values. + + :param nuggets: List of information nuggets with cosine similarity values. + """ self.reset() self.data = [(_create_sanitized_text(nugget), @@ -593,11 +608,18 @@ def update_data(self, nuggets): @track_button_click("show bar chart") def show_bar_chart(self): + """ + Displays the bar chart using the current data. If no data is available, the method returns early. Represents a button + """ if not self.data: return self.plot_bar_chart() def _unique_nuggets(self): + """ + Ensures that only the most relevant (i.e., minimal cosine distance) nuggets are included in the data. + Filters out duplicates based on text, keeping only the lowest cosine distance for each unique nugget. + """ min_dict = {} for item in self.data: key, value = item @@ -606,6 +628,10 @@ def _unique_nuggets(self): self.data = [(key, min_dict[key]) for key in min_dict] def plot_bar_chart(self): + """ + Generates and displays the bar chart with cosine-based certainty values. + Includes interactive functionality for annotations and customizable axes. + """ self._unique_nuggets() if self.window is not None: self.window.close() @@ -746,6 +772,12 @@ def plot_bar_chart(self): dialog.exec() def on_pick(self, event): + """ + Handles click events on the bar chart. When a bar is clicked, displays an annotation + with detailed information about the clicked nugget. + + :param event: The pick event triggered by clicking a bar. + """ if isinstance(event.artist, Rectangle): patch = event.artist index = self.bar.get_children().index(patch) @@ -765,10 +797,16 @@ def on_pick(self, event): self.bar_chart_canvas.draw_idle() def reset(self): + """ + Resets the state of the bar chart widget, clearing any previously stored data and bars. + """ self.data = [] self.bar = None def showWindowEvent(self, event): + """ + These and method below needed for tracking how much time user spent on the bar chart + """ super().showEvent(event) Tracker().start_timer(str(self.__class__)) From 03505bdd4ba08cab248c5fc1859a4bd766fe08a4 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 28 Sep 2024 02:06:14 +0200 Subject: [PATCH 72/85] add remaining documentation to visualizations.py --- wannadb_ui/visualizations.py | 539 +++++++++++++++++++++++++++++------ 1 file changed, 457 insertions(+), 82 deletions(-) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index f96bedad..91256525 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -131,24 +131,61 @@ def __init__(self, point_meaning: str, point_color: QColor): class EmbeddingVisualizerLegend(QWidget): + """ + Class realizing a legend for a 3D-Grid realized by the EmbeddingVisualizer class which explains the meaning of all + point colors occurring within the grid. + Utilizes instances of `PointLegend` to explain the meaning of a specific color. + + Methods + ------- + reset(): + Removes all widgets - realized as instances of `PointLegend` - contained within this widget. + update_colors_and_meanings(colors_with_meanings: List[Tuple[QColor, str]]): + Fills this instance with an actual legend explaining the given colors with the given meanings. + """ + def __init__(self): + """ + Initializes an instance of this class by creating and setting up the corresponding layout. + Initially the widget represented by this instance is empty and doesn't contain anything except an empty layout. + """ + super().__init__() + # Set up the layout self.layout = QHBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) self.setLayout(self.layout) + # Init the list of PointLegends contained by this instance self._point_legends = [] def reset(self): + """ + Removes all widgets - realized as instances of `PointLegend`. + + After calling this method, the widget represented by this instance is empty and doesn't contain anything except + an empty layout. + """ + for widget in self._point_legends: self.layout.removeWidget(widget) self._point_legends = [] def update_colors_and_meanings(self, colors_with_meanings: List[Tuple[QColor, str]]): + """ + Fills this instance with an actual legend explaining the given colors with the given meanings. + + First, this instance is cleared by calling the `reset()` method. + Then an explanation for each of the given colors is created by creating `PointLegend` instances for each color + with its associated meaning and added to the widget represented by this instance. + """ + + # Clear this widget self.reset() + # Add new explanations for color, meaning in colors_with_meanings: point_legend = PointLegend(meaning, color) self.layout.addWidget(point_legend) @@ -156,6 +193,36 @@ def update_colors_and_meanings(self, colors_with_meanings: List[Tuple[QColor, st class EmbeddingVisualizer: + """ + Class providing the required logic to handle a 3D-Grid displaying dimension-reduced embedding vectors. + + Methods + ------- + enable_accessible_color_palette_(): + Replaces the colors of points displayed within the grid by accessible colors allowing people with color + blindness to better differentiate the colors. + disable_accessible_color_palette_(): + Replaces the colors of points displayed within the grid by the originally used colors and therefore disables the + usage of accessible colors. + update_and_display_params(attribute: Attribute, + nuggets: List[InformationNugget], + currently_highlighted_nugget: Union[InformationNugget, None], + best_guess: Union[InformationNugget, None], + other_best_guesses: List[InformationNugget]): + Removes all currently displayed nuggets and adds the given attribute as well as the nuggets to the grid. + highlight_best_guess(best_guess: InformationNugget): + Highlights the point representing the given nugget by increasing its size and dying it white. + highlight_selected_nugget(newly_selected_nugget: InformationNugget): + Highlights the point representing the given nugget by increasing its size and dying it blue. + display_other_best_guesses(other_best_guesses: List[InformationNugget]): + Adds the given nuggets - corresponding to the best guesses of other documents - to the grid and highlight them + by dying them yellow. + remove_other_best_guesses(other_best_guesses: List[InformationNugget]): + Removes the nuggets corresponding to the best guesses of other documents from the grid. + reset(): + Removes all points and their corresponding annotation text from the grid. + """ + def __init__(self, legend: EmbeddingVisualizerLegend, colors_with_meanings: List[Tuple[AccessibleColor, str]], @@ -165,6 +232,35 @@ def __init__(self, best_guess: InformationNugget = None, other_best_guesses: List[InformationNugget] = None, accessible_color_palette: bool = False): + """ + Parameters: + ----------- + legend: EmbeddingVisualizerLegend + Instance of the legend displayed below the grid and explaining the meaning of the colors occurring in the + grid. + colors_with_meanings: List[Tuple[QColor, str]] + List of colors occurring in the grid associated with their meaning used to fill the given legend. + attribute: Attribute = None + `Attribute` instance representing the attribute to which the nuggets displayed within the grid belong to as + its embedding is displayed in the grid as well. + nuggets: List[InformationNugget] = None + Nuggets whose dimension-reduced embedding vectors should be displayed within the grid. + currently_highlighted_nugget: InformationNugget = None + Refers to the nugget which is currently selected and therefore should be highlighted. If none, nothing is + highlighted. + best_guess: InformationNugget = None + Refers to the best guess of the document represented by this grid and therefore should be highlighted. If + none, nothing is highlighted. Applicable only in case the grid belongs to the document view and not to the + document overview screen. + other_best_guesses: List[InformationNugget] = None + Nuggets representing best guesses from other documents which should be displayed in this grid as well + initially. Applicable only in case the grid belongs to the document view and not to the document overview + screen. + accessible_color_palette: bool + Specifies whether the colors used by the points displayed in the grid are accessible - usable for people + with color blindness - or not. + """ + self._attribute: Attribute = attribute self._nuggets: List[InformationNugget] = nuggets self._currently_highlighted_nugget: InformationNugget = currently_highlighted_nugget @@ -175,13 +271,25 @@ def __init__(self, self._accessible_color_palette = accessible_color_palette self._legend = legend self._colors_with_meanings = colors_with_meanings + + # Add the given colors with their meanings to the given legend self._update_legend() def enable_accessible_color_palette_(self): + """ + Replaces the colors of points displayed within the grid by accessible colors allowing people with color + blindness to better differentiate the colors. + """ + self._accessible_color_palette = True self._update_legend() def disable_accessible_color_palette_(self): + """ + Replaces the colors of points displayed within the grid by the originally used colors and therefore disables the + usage of accessible colors. + """ + self._accessible_color_palette = False self._update_legend() @@ -191,26 +299,56 @@ def update_and_display_params(self, currently_highlighted_nugget: Union[InformationNugget, None], best_guess: Union[InformationNugget, None], other_best_guesses: List[InformationNugget]): + """ + Removes all currently displayed nuggets and adds the given attribute as well as the nuggets to the grid. + + First, removes all currently displayed points. + Then adds the dimension-reduced embedding vector of the given attribute and the given nuggets to the grid. + Next, the given best guess and `currently_highlighted_nugget` and - if this grid belongs to the document + overview - already confirmed matches are highlighted. + + Parameters: + ----------- + attribute: Attribute + `Attribute` instance representing the attribute to which the nuggets displayed within the grid as its + embedding is displayed in the grid as well. + nuggets: List[InformationNugget] + Nuggets whose dimension-reduced embedding vectors should be displayed within the grid. + currently_highlighted_nugget: InformationNugget + Special nugget which should be highlighted. If none, nothing is highlighted. + best_guess: InformationNugget + Best guess of the document corresponding to the grid which is highlighted. If none, nothing is highlighted. + Applicable only in case the grid belongs to the document view and not to the document overview screen. + other_best_guesses: List[InformationNugget] + Best guesses of other documents which should be displayed in the grid as well. Applicable only in case the + grid belongs to the document view and not to the document overview screen. + """ + self.reset() + # Add attribute to grid if attribute is not None: - self.display_attribute_embedding(attribute) + self._display_attribute_embedding(attribute) else: logger.warning("Given attribute is null, can not display.") + # Add nuggets to the grid if nuggets: self._nuggets = nuggets - self.display_nugget_embeddings(nuggets) + self._display_nugget_embeddings(nuggets) else: logger.warning("Given nugget list is null or empty, can not display.") + # Highlight best guess if present if best_guess is not None: self.highlight_best_guess(best_guess) else: logger.info("Given best_guess is null, can not highlight.") - self.highlight_confirmed_matches() + # Highlight confirmed matches if possible + self._highlight_confirmed_matches() + # Highlight currently selected nugget if possible if currently_highlighted_nugget is not None: self.highlight_selected_nugget(currently_highlighted_nugget) else: @@ -218,78 +356,129 @@ def update_and_display_params(self, self._other_best_guesses = other_best_guesses - def add_item_to_grid(self, - nugget_to_display_context: Tuple[Union[InformationNugget, Attribute], Color], - annotation_text: str, - size: int = DEFAULT_NUGGET_SIZE): - item_to_display, color = nugget_to_display_context - position = np.array([item_to_display[PCADimensionReducedTextEmbeddingSignal]]) if isinstance(item_to_display, - InformationNugget) \ - else np.array([item_to_display[PCADimensionReducedLabelEmbeddingSignal]]) - - scatter = GLScatterPlotItem(pos=position, color=color, size=size, pxMode=True) - annotation = GLTextItem(pos=[position[0][0], position[0][1], position[0][2]], - color=WHITE, - text=annotation_text, - font=EMBEDDING_ANNOTATION_FONT) - - self._gl_widget.addItem(scatter) - self._gl_widget.addItem(annotation) + def highlight_best_guess(self, best_guess: InformationNugget): + """ + Highlights the point representing the given nugget by increasing its size and dying it white. - if isinstance(item_to_display, InformationNugget): - self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) + If the best guess is equal to the currently selected nugget, it's highlighted in blue. + """ - def highlight_best_guess(self, best_guess: InformationNugget): + # Update internal attribute self._best_guess = best_guess + # Highlight in blue if equal to currently selected nugget if self._best_guess == self._currently_highlighted_nugget: self._highlight_nugget(self._best_guess, ACC_BLUE if self._accessible_color_palette else BLUE, 15) return + # Highlight given nugget in white and increase size self._highlight_nugget(self._best_guess, WHITE, 15) def highlight_selected_nugget(self, newly_selected_nugget: InformationNugget): + """ + Highlights the point representing the given nugget by increasing its size and dying it blue. + + If present, the previously selected nugget is reset to original color and size. Exact reset color and size + depend on type of previously selected nugget (best guess, confirmed match, normal nugget) + """ + + # Determine highlight color and size as well as reset color and size. Highlight values are always blue and 15 + # while reset values depend on type of previously selected nugget (see above). (highlight_color, highlight_size), (reset_color, reset_size) = self._determine_update_values( - previously_selected_nugget=self._currently_highlighted_nugget, - newly_selected_nugget=newly_selected_nugget) + previously_selected_nugget=self._currently_highlighted_nugget) + # Reset currently highlighted nugget to determined color and size if self._currently_highlighted_nugget is not None: currently_highlighted_scatter, _ = self._nugget_to_displayed_items[self._currently_highlighted_nugget] currently_highlighted_scatter.setData(color=reset_color, size=reset_size) + # Highlight newly selected nugget self._highlight_nugget(nugget_to_highlight=newly_selected_nugget, new_color=highlight_color, new_size=highlight_size) + # Update internal variable self._currently_highlighted_nugget = newly_selected_nugget def display_other_best_guesses(self, other_best_guesses: List[InformationNugget]): + """ + Adds the given nuggets - corresponding to the best guesses of other documents - to the grid and highlight them + by dying them yellow. + """ + for other_best_guess in other_best_guesses: self._add_other_best_guess(other_best_guess) def remove_other_best_guesses(self, other_best_guesses: List[InformationNugget]): - self.remove_nuggets_from_widget(other_best_guesses) + """ + Removes the nuggets corresponding to the best guesses of other documents from the grid. + """ + + self._remove_nuggets_from_widget(other_best_guesses) + + def reset(self): + """ + Removes all points and their corresponding annotation text from the grid. + """ + + # Remove widgets + for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): + self._gl_widget.removeItem(scatter) + self._gl_widget.removeItem(annotation) + + # Reset internal state variables + self._nugget_to_displayed_items = {} + self._currently_highlighted_nugget = None + self._best_guess = None + + def _add_item_to_grid(self, + nugget_to_display_context: Tuple[Union[InformationNugget, Attribute], Color], + annotation_text: str, + size: int = DEFAULT_NUGGET_SIZE): + # Determine position of point to display and its color + item_to_display, color = nugget_to_display_context + position = np.array([item_to_display[PCADimensionReducedTextEmbeddingSignal]]) if isinstance(item_to_display, + InformationNugget) \ + else np.array([item_to_display[PCADimensionReducedLabelEmbeddingSignal]]) + + # Create grid items representing the given nugget and annotation text at the computed position + scatter = GLScatterPlotItem(pos=position, color=color, size=size, pxMode=True) + annotation = GLTextItem(pos=[position[0][0], position[0][1], position[0][2]], + color=WHITE, + text=annotation_text, + font=EMBEDDING_ANNOTATION_FONT) + + # Add created items to grid + self._gl_widget.addItem(scatter) + self._gl_widget.addItem(annotation) + + # Add created items to internal variable to keep track about the added items + if isinstance(item_to_display, InformationNugget): + self._nugget_to_displayed_items[item_to_display] = (scatter, annotation) - def display_nugget_embeddings(self, nuggets): + def _display_nugget_embeddings(self, nuggets): for nugget in nuggets: nugget_to_display_context = (nugget, self._determine_nuggets_color(nugget)) - self.add_item_to_grid(nugget_to_display_context=nugget_to_display_context, - annotation_text=_build_nuggets_annotation_text(nugget)) + self._add_item_to_grid(nugget_to_display_context=nugget_to_display_context, + annotation_text=_build_nuggets_annotation_text(nugget)) - def display_attribute_embedding(self, attribute): - self.add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self._accessible_color_palette else RED), - annotation_text=attribute.name) - self._attribute = attribute # save for later use + def _display_attribute_embedding(self, attribute): + self._add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self._accessible_color_palette else RED), + annotation_text=attribute.name) + self._attribute = attribute - def remove_nuggets_from_widget(self, nuggets_to_remove): + def _remove_nuggets_from_widget(self, nuggets_to_remove): + # Removes all items associated with the given nuggets from the grid for nugget in nuggets_to_remove: scatter, annotation = self._nugget_to_displayed_items.pop(nugget) self._gl_widget.removeItem(scatter) self._gl_widget.removeItem(annotation) - def highlight_confirmed_matches(self): + def _highlight_confirmed_matches(self): + # Only relevant if the grid belongs to the document overview view as it highlights the nuggets which are already + # confirmed by the user in the feedback process. if self._attribute is None: logger.warning("Attribute has not been initialized yet, can not highlight confirmed matches.") return @@ -299,21 +488,15 @@ def highlight_confirmed_matches(self): self._highlight_nugget(confirmed_match, ACC_GREEN if self._accessible_color_palette else GREEN, DEFAULT_NUGGET_SIZE) - def reset(self): - for nugget, (scatter, annotation) in self._nugget_to_displayed_items.items(): - self._gl_widget.removeItem(scatter) - self._gl_widget.removeItem(annotation) - - self._nugget_to_displayed_items = {} - self._currently_highlighted_nugget = None - self._best_guess = None - - def _determine_update_values(self, previously_selected_nugget, newly_selected_nugget) -> ( - (int, Color), (int, Color)): + def _determine_update_values(self, previously_selected_nugget) -> ((int, Color), (int, Color)): + # Computes the size and color of a newly selected nugget as well as the size and color of the nugget + # which was selected previously + # Highlight values are always same highlight_color = ACC_BLUE if self._accessible_color_palette else BLUE - highlight_size = 15 if newly_selected_nugget == self._best_guess else 10 + highlight_size = 15 + # Reset values depend on the type of the nugget whose size and color should be reset if previously_selected_nugget is None: reset_color = WHITE reset_size = DEFAULT_NUGGET_SIZE @@ -331,6 +514,11 @@ def _determine_update_values(self, previously_selected_nugget, newly_selected_nu return (highlight_color, highlight_size), (reset_color, reset_size) def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: + # Computes the nuggets color based on its type: + # Purple -> Failure during computation + # White -> Below threshold + # Red -> Above Threshold + if (self._attribute is None or CurrentThresholdSignal.identifier not in self._attribute.signals): logger.warning(f"Could not determine nuggets color from given attribute: {self._attribute}. " @@ -345,6 +533,8 @@ def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: else ACC_RED if self.accessible_color_palette else RED) def _add_grids(self): + # Adds the UI items realizing the grid + grid_xy = gl.GLGridItem() self._gl_widget.addItem(grid_xy) @@ -366,13 +556,18 @@ def _highlight_nugget(self, nugget_to_highlight, new_color, new_size): scatter_to_highlight.setData(color=new_color, size=new_size) def _add_other_best_guess(self, other_best_guess): - self.add_item_to_grid( + self._add_item_to_grid( nugget_to_display_context=(other_best_guess, ACC_YELLOW if self._accessible_color_palette else YELLOW), annotation_text=_build_nuggets_annotation_text(other_best_guess), size=HIGHLIGHT_SIZE) def _update_legend(self): + # Updates the legend associated with this grid according to the current value of the internal variables + # `_color_with_meanings` and `_accessible_color_palette` + def map_to_correct_color(accessible_color): + # Maps each color to its standard or accessible version depending on the value of + # `_accessible_color_palette` return accessible_color.corresponding_accessible_color if self._accessible_color_palette \ else accessible_color.color @@ -383,6 +578,18 @@ def map_to_correct_color(accessible_color): class EmbeddingVisualizerWindow(EmbeddingVisualizer, QMainWindow): + """ + Class realizing an `EmbeddingVisualizer` in a separate window by inheriting from `EmbeddingVisualizer` and + `QMainWindow`. + + Methods + ------- + showEvent(): + Shows the associated window. + closeEvent(): + Closes the associated window. + """ + def __init__(self, colors_with_meanings: List[Tuple[AccessibleColor, str]], attribute: Attribute = None, @@ -391,6 +598,38 @@ def __init__(self, best_guess: InformationNugget = None, other_best_guesses: List[InformationNugget] = None, accessible_color_palette: bool = False): + """ + Initializes an instance of this class by calling constructor of `EmbeddingVisualizer` and `QMainWindow` and sets + up the required UI components. + The parameters are propagated to the `EmbeddingVisualizer` constructor in order to add content to the grid + initially. + + Parameters + ---------- + colors_with_meanings: List[Tuple[QColor, str]] + List of colors occurring in the grid associated with their meaning used to fill the given legend. + attribute: Attribute = None + `Attribute` instance representing the attribute to which the nuggets displayed within the grid belong to as + its embedding is displayed in the grid as well. + nuggets: List[InformationNugget] = None + Nuggets whose dimension-reduced embedding vectors should be displayed within the grid. + currently_highlighted_nugget: InformationNugget = None + Refers to the nugget which is currently selected and therefore should be highlighted. If none, nothing is + highlighted. + best_guess: InformationNugget = None + Refers to the best guess of the document represented by this grid and therefore should be highlighted. If + none, nothing is highlighted. Applicable only in case the grid belongs to the document view and not to the + document overview screen. + other_best_guesses: List[InformationNugget] = None + Nuggets representing best guesses from other documents which should be displayed in this grid as well + initially. Applicable only in case the grid belongs to the document view and not to the document overview + screen. + accessible_color_palette: bool + Specifies whether the colors used by the points displayed in the grid are accessible - usable for people + with color blindness - or not. + """ + + # Call super constructors EmbeddingVisualizer.__init__(self, legend=EmbeddingVisualizerLegend(), colors_with_meanings=colors_with_meanings, @@ -400,21 +639,24 @@ def __init__(self, best_guess=best_guess, accessible_color_palette=accessible_color_palette) QMainWindow.__init__(self) - self.accessible_color_palette = accessible_color_palette + # Set up window self.setWindowTitle("3D Grid Visualizer") self.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) + # Set up layout central_widget = QWidget() self.setCentralWidget(central_widget) self.fullscreen_layout = QVBoxLayout() central_widget.setLayout(self.fullscreen_layout) + # Add grid and legend item to the UI self.fullscreen_layout.addWidget(self._gl_widget, stretch=7) self.fullscreen_layout.addWidget(self._legend, stretch=1) self._add_grids() + # If values which should be displayed in the grid are present, add them the grid, else make itself invisible if (attribute is not None and nuggets is not None and currently_highlighted_nugget is not None and @@ -425,26 +667,62 @@ def __init__(self, self.setVisible(False) def showEvent(self, event): + """ + Shows the associated window and start timer tracking the time, the window is opened. + """ + super().showEvent(event) Tracker().start_timer(str(self.__class__)) - def _enable_accessible_color_palette(self): - self.accessible_color_palette = True - self.enable_accessible_color_palette_() - - def _disable_accessible_color_palette(self): - self.accessible_color_palette = False - self.disable_accessible_color_palette_() - def closeEvent(self, event): + """ + Closes the associated window and stop timer tracking the time, the window is opened. + """ + Tracker().stop_timer(str(self.__class__)) event.accept() class EmbeddingVisualizerWidget(EmbeddingVisualizer, QWidget): + """ + Class realizing an `EmbeddingVisualizer` within a widget by inheriting from `EmbeddingVisualizer` and `QWidget`. + + Each instance of this visualizer is associated with a fullscreen version which displays the same content and can + be opened and closed with buttons. + + Methods + ------- + enable_accessible_color_palette(): + Enables accessible color palette in this visualizer as well as in fullscreen version if opened. + disable_accessible_color_palette(): + Disables accessible color palette in this visualizer as well as in fullscreen version if opened. + return_from_embedding_visualizer_window(self): + Close fullscreen version of this visualizer. + update_other_best_guesses(): + Update variable holding best guesses from other documents. + highlight_selected_nugget(nugget): + Highlights selected nugget in this visualizer as well in fullscreen version if opened. + highlight_best_guess(best_guess: InformationNugget): + Highlights the best guess of the corresponding document in this visualizer as well in fullscreen version if + opened. + reset(): + Resets this widget by calling superclass implementation and resetting internal variables. + """ + tracker: Tracker = Tracker() def __init__(self): + """ + Initializes an instance of this class by determining the colors with their associated meanings used by the + corresponding grid, calling the super constructors and setting up the required UI components. + + Required UI components cover the 3D grid, as well as buttons to show grid in separate window as well as adding / + removing best guesses from other documents to / from the grid. + + The `EmbeddingVisualizer` is initialized without any nuggets leading to an initially empty grid. + """ + + # Determine colors with their associated meanings used by the corresponding grid colors_with_meanings = [ (AccessibleColor(WHITE, WHITE), 'Below threshold'), (AccessibleColor(RED, ACC_RED), 'Above threshold'), @@ -452,19 +730,24 @@ def __init__(self): (AccessibleColor(YELLOW, ACC_YELLOW), 'Other documents best matches'), (AccessibleColor(PURPLE, ACC_PURPLE), 'Could not determine correct color') ] + + # Call super constructors EmbeddingVisualizer.__init__(self, EmbeddingVisualizerLegend(), colors_with_meanings) QWidget.__init__(self) + # Set up layout self.layout = QVBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) self.setLayout(self.layout) + # Set up grid widget and add to layout self._gl_widget.setMinimumHeight(300) # Set the initial height of the grid to 200 self.layout.addWidget(self._gl_widget) self.layout.addWidget(self._legend) + # Set up buttons and add to layout self.best_guesses_widget = QWidget() self.best_guesses_widget_layout = QHBoxLayout(self.best_guesses_widget) self.best_guesses_widget_layout.setContentsMargins(0, 0, 0, 0) @@ -480,59 +763,124 @@ def __init__(self): self.remove_other_best_guesses_button.clicked.connect(self._handle_remove_other_best_guesses_clicked) self.best_guesses_widget_layout.addWidget(self.remove_other_best_guesses_button) self.layout.addWidget(self.best_guesses_widget) - self.accessible_color_palette = False + # Add items representing the grid itself to the grid widget self._add_grids() + # Init internal variables self._fullscreen_window = None self._other_best_guesses = None - @track_button_click("fullscreen embedding visualizer") - def _show_embedding_visualizer_window(self): - if self._fullscreen_window is None: - self._fullscreen_window = EmbeddingVisualizerWindow(colors_with_meanings=self._colors_with_meanings, - attribute=self._attribute, - nuggets=list(self._nugget_to_displayed_items.keys()), - currently_highlighted_nugget=self._currently_highlighted_nugget, - best_guess=self._best_guess) - self._fullscreen_window.show() - def enable_accessible_color_palette(self): - self.accessible_color_palette = True - if self._fullscreen_window is None: - pass - else: + """ + Invokes `enable_accessible_color_palette()` method of the `EmbeddingVisualizer` superclass for this instance. If + present, invokes the same method on the `EmbeddingVisualizer` instance realizing the fullscreen version of + this visualizer to enable accessible color palette there as well. + + More detailed information about the `enable_accessible_color_palette()` method are elaborated in implementation + of superclass. + """ + + # Call superclass implementation to enable accessible color palette on this grid + super().enable_accessible_color_palette_() + + # Enable accessible color palette in fullscreen window if present + if self._fullscreen_window is not None: self._fullscreen_window.enable_accessible_color_palette_() def disable_accessible_color_palette(self): - self.accessible_color_palette = False - if self._fullscreen_window is None: - pass - else: + """ + Invokes `disable_accessible_color_palette()` method of the `EmbeddingVisualizer` superclass for this instance. + If present, invokes the same method on the `EmbeddingVisualizer` instance realizing the fullscreen version of + this visualizer to disable accessible color palette there as well. + + More detailed information about the `disable_accessible_color_palette()` method are elaborated in implementation + of superclass. + """ + + # Call superclass implementation to disable accessible color palette on this grid + super().disable_accessible_color_palette_() + + # Disable accessible color palette in fullscreen window if present + if self._fullscreen_window is not None: self._fullscreen_window.disable_accessible_color_palette_() def return_from_embedding_visualizer_window(self): + """ + Close fullscreen version of this visualizer. + """ + self._fullscreen_window.close() self._fullscreen_window = None - def update_other_best_guesses(self, other_best_guesses): + def update_other_best_guesses(self, other_best_guesses: List[InformationNugget]): + """ + Update variable holding best guesses from other documents. + + Parameters + ---------- + other_best_guesses: List[InformationNugget] + List of other best guesses from other documents to which the internal variable should be updated. + """ + self._other_best_guesses = other_best_guesses - def highlight_selected_nugget(self, nugget): - super().highlight_selected_nugget(nugget) + def highlight_selected_nugget(self, selected_nugget: InformationNugget): + """ + Highlights selected nugget in this visualizer as well in fullscreen version if present. + More details are provided in documentation of implementation in `EmbeddingVisualizer`. + + Realized by calling implementation in `EmbeddingVisualizer` of this method and same method on fullscreen version + of this visualizer. + + Parameters + ---------- + selected_nugget: InformationNugget + Nugget whose representation in the grid should be highlighted. + """ + # Highlight selected nugget in this visualizer + super().highlight_selected_nugget(selected_nugget) + + # Highlight selected nugget in fullscreen version if self._fullscreen_window is not None: - self._fullscreen_window.highlight_selected_nugget(nugget) + self._fullscreen_window.highlight_selected_nugget(selected_nugget) def highlight_best_guess(self, best_guess: InformationNugget): + """ + Highlights the best guess of the corresponding document in this visualizer as well in fullscreen version if + present. + More details are provided in documentation of implementation in `EmbeddingVisualizer`. + + Realized by calling implementation in `EmbeddingVisualizer` of this method and same method on fullscreen + version of this visualizer. + + Applicable only if this visualizer belongs to the document view as only in this case the visualizer covers one + document providing only one best guess. + + Parameters + ---------- + best_guess: InformationNugget + Nugget whose representation in the grid should be highlighted. + """ + + # Highlight selected nugget in this visualizer super().highlight_best_guess(best_guess) + # Highlight selected nugget in fullscreen version if self._fullscreen_window is not None: self._fullscreen_window.highlight_best_guess(best_guess) def reset(self): + """ + Resets this widget by calling superclass implementation and resetting internal variables. + More details are provided in documentation of superclass implementation. + """ + + # Call superclass implementation super().reset() + # Reset internal variables self._fullscreen_window = None self._other_best_guesses = None @@ -540,32 +888,59 @@ def reset(self): self.remove_other_best_guesses_button.setEnabled(False) def hide(self): + """ + Hide this widget and close fullscreen version if present. + """ + super().hide() if self._fullscreen_window is not None: self._fullscreen_window.close() + @track_button_click("fullscreen embedding visualizer") + def _show_embedding_visualizer_window(self): + # Opens the fullscreen version of this visualizer and track that the corresponding has been clicked. + + if self._fullscreen_window is None: + self._fullscreen_window = EmbeddingVisualizerWindow(colors_with_meanings=self._colors_with_meanings, + attribute=self._attribute, + nuggets=list(self._nugget_to_displayed_items.keys()), + currently_highlighted_nugget=self._currently_highlighted_nugget, + best_guess=self._best_guess) + self._fullscreen_window.show() + @track_button_click(button_name="show other best guesses from other documents") def _handle_show_other_best_guesses_clicked(self): + # Adds the best guesses from other documents - contained in the internal variable `_other_best_guesses` - to + # this visualizer and the fullscreen version if opened + # Track that the corresponding button has been clicked. + + # Log warning if no other best guesses are available if self._other_best_guesses is None: logger.warning("Can not display best guesses from other documents as these best guesses have not been " "initialized yet.") return + # Only the currently applicable button of the buttons to add and remove other best guesses should be enabled self.show_other_best_guesses_button.setEnabled(False) self.remove_other_best_guesses_button.setEnabled(True) + # Add other best guesses to this visualizer and fullscreen version if opened self.display_other_best_guesses(self._other_best_guesses) if self._fullscreen_window is not None: self._fullscreen_window.display_other_best_guesses(self._other_best_guesses) @track_button_click(button_name="stop showing other best guesses from other documents") def _handle_remove_other_best_guesses_clicked(self): + # Removes the best guesses from other documents - contained in the internal variable `_other_best_guesses` - + # from this visualizer and the fullscreen version if opened. + # Track that the corresponding button has been clicked. + self.show_other_best_guesses_button.setEnabled(True) self.remove_other_best_guesses_button.setEnabled(False) - self.remove_nuggets_from_widget(self._other_best_guesses) + self._remove_nuggets_from_widget(self._other_best_guesses) if self._fullscreen_window is not None: - self._fullscreen_window.remove_nuggets_from_widget(self._other_best_guesses) + self._fullscreen_window._remove_nuggets_from_widget(self._other_best_guesses) dialog = InfoDialog() From 503310c386a88c59287c8b691cb1653961db2f53 Mon Sep 17 00:00:00 2001 From: nils-bz-surface Date: Sat, 28 Sep 2024 09:57:08 +0200 Subject: [PATCH 73/85] start documenting data_insights.py --- wannadb_ui/data_insights.py | 166 ++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 25 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 0eba3244..09ffe67e 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -1,3 +1,7 @@ +""" +Module providing logic to realize Data Insights section visible in the document overview screen. +""" + import abc import random from typing import Generic, TypeVar, List, Tuple @@ -12,29 +16,71 @@ from wannadb_ui.study import track_button_click from wannadb_ui.visualizations import EmbeddingVisualizerWindow +# Refers to the type of items displayed in a ChangesList UPDATE_TYPE = TypeVar("UPDATE_TYPE") class ChangesList(QWidget, Generic[UPDATE_TYPE]): + """ + This class realizes a QWidget representing a list of updates. + These updates refer to changes induced by the latest user feedback. + + Methods + ------- + update_list(self, updates: List[UPDATE_TYPE]): + Updates the list with the given list of items. + """ + def __init__(self, info_label_text, tooltip_text): + """ + Initializes an empty UI ChangesList with the given name and tooltip. + + Parameters + ---------- + info_label_text : str + Name of this list displayed next to the list itself. + tooltip_text: QColor + Text further explaining the list's intention displayed if hovering over list's name + """ + super(ChangesList, self).__init__() + # Setup layout self._layout: QHBoxLayout = QHBoxLayout(self) self._layout.setSpacing(0) self._layout.setContentsMargins(0, 0, 0, 0) - self._add_info_label(info_label_text, tooltip_text) + # Init and add name label and tooltip + self._info_label: QLabel = QLabel(info_label_text) + self._info_label.setContentsMargins(0, 0, 8, 0) + self._list_labels: List[QWidget] = list() + self._layout.addWidget(self._info_label) + self._info_label.setToolTip(tooltip_text) def update_list(self, updates: List[UPDATE_TYPE]): + """ + Updates the list by the given list of items. + + First it removes all items from the current list and then adds the new items represented by the given list. + In order to keep the UI clear, we only add the seven randomly sampled items of the given list to the UI list. + The existence of further - not displayed - items are indicated by a label displaying "... and + [NUMBER_OF_MISSING_ITEMS] more.". + + Parameters + ---------- + """ + self._reset_list() if len(updates) == 0: + # We don't want to have a list containing nothing but at least some symbol indicating that the list is empty no_changes_label = QLabel("-") no_changes_label.setContentsMargins(0, 0, 0, 0) self._layout.addWidget(no_changes_label) self._list_labels.append(no_changes_label) return + # Select the 7 items to be displayed and add them to the UI updates_to_add = random.sample(updates, k=min(7, len(updates))) for update in updates_to_add: label_text, tooltip_text = self._create_label_and_tooltip_text(update) @@ -52,31 +98,42 @@ def update_list(self, updates: List[UPDATE_TYPE]): @abc.abstractmethod def _create_label_and_tooltip_text(self, update: UPDATE_TYPE) -> Tuple[str, str]: + # Computes the label text and tooltip corresponding to an update depending on the actual type of the update pass def _reset_list(self): + # Removes all items from the UI list for list_label in self._list_labels: self._layout.removeWidget(list_label) self._list_labels = [] - def _add_info_label(self, info_label_text: str, tooltip_text: str): - self._info_label: QLabel = QLabel(info_label_text) - self._info_label.setContentsMargins(0, 0, 8, 0) - self._list_labels: List[QWidget] = list() - self._layout.addWidget(self._info_label) - - self._info_label.setToolTip(tooltip_text) - class ChangedBestMatchDocumentsList(ChangesList[BestMatchUpdate]): - def __init__(self): + """ + Realizes a ChangesList displaying changed best matches after each user feedback which can be found within the + Data Insights section. + + Methods + ------ + update_list(self, updates: List[UPDATE_TYPE]): + see `update_list` of `ChangesList` + """ + + def __init__(self, addressed_change: ThresholdPosition): + """ + Determines its tooltip text and name and initializes itself by calling super constructor. + """ + + self._addressed_change = addressed_change tooltip_text = ("The distance associated with each nugget is recomputed after every feedback round.\n" "Therefore the best guess of an document (nugget with lowest distance) might change " "after a feedback round. Such best guesses are listed here.") + super(ChangedBestMatchDocumentsList, self).__init__("Changed best guesses:", tooltip_text) def _create_label_and_tooltip_text(self, update: BestMatchUpdate) -> Tuple[str, str]: + # Computes the text and tooltip which should represent the given item in the UI list. label_text = f"{update.new_best_match} {'(' + str(update.count) + ')' if update.count > 1 else ''}" tooltip_text = (f"Previous best match was: {update.old_best_match}\n" f"Changes to token \"{update.new_best_match}\": {update.count}") @@ -85,10 +142,36 @@ def _create_label_and_tooltip_text(self, update: BestMatchUpdate) -> Tuple[str, class ChangedThresholdPositionList(ChangesList[ThresholdPositionUpdate]): - def __init__(self, info_label_text, tooltip_text): + """ + Realizes an abstract ChangesList displaying nuggets whose threshold position changed (either above or below) due to + the latest user feedback which can be found in the Data Insights section. + """ + + def __init__(self, info_label_text: str, tooltip_text: str, addressed_change: ThresholdPosition): + """ + Initializes itself by calling super constructor. + + Parameters: + ----------- + info_label_text : str + Name of this list displayed next to the list itself. + tooltip_text: QColor + Text further explaining the list's intention displayed if hovering over list's name + addressed_change + Determines about which threshold position updates this list cares, either from above to below or below to + above. + The given position refers to the end position of the relevant updates (E.g. If it's 'below', then this list + only cares about 'above' -> 'below' updates). + """ + + self._addressed_change = addressed_change + super(ChangedThresholdPositionList, self).__init__(info_label_text, tooltip_text) def update_list(self, threshold_updates: List[ThresholdPositionUpdate]): + """ + Extracts the relevant updates out of the given list matching + """ relevant_updates = self._extract_relevant_updates(threshold_updates) super().update_list(relevant_updates) @@ -105,47 +188,72 @@ def _create_label_and_tooltip_text(self, update: ThresholdPositionUpdate) -> Tup return label_text, tooltip_text - @abc.abstractmethod def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]) -> List[ThresholdPositionUpdate]: - pass + return list(filter(lambda update: (update.old_position != update.new_position and + update.new_position == self._addressed_change), + threshold_updates)) class ChangedThresholdPositionToAboveList(ChangedThresholdPositionList): + """ + Realizes a concrete `ChangedThresholdPositionList` displaying threshold updates where the position changed from + below to above. + """ + def __init__(self): + """ + Initializes itself by determining tooltip, name and calling super constructor + """ + tooltip_text = ("The distance associated with each nugget as well as the threshold is recomputed after every " "feedback round.\n" "Therefore the best guess of an document might not be below the threshold anymore. Such best " "guesses are listed here.") - super(ChangedThresholdPositionToAboveList, self).__init__("Moved above threshold:", tooltip_text) - - def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]): - return list(filter(lambda update: (update.old_position != update.new_position and - update.new_position == ThresholdPosition.ABOVE), - threshold_updates)) + super(ChangedThresholdPositionToAboveList, self).__init__("Moved above threshold:", + tooltip_text, + ThresholdPosition.ABOVE) class ChangedThresholdPositionToBelowList(ChangedThresholdPositionList): + """ + Realizes a concrete `ChangedThresholdPositionList` displaying threshold updates where the position changed from + above to below. + """ + def __init__(self): + """ + Initializes itself by determining tooltip, name and calling super constructor + """ + tooltip_text = ("The distance associated with each nugget as well as the threshold is recomputed after every " "feedback round.\n" "Therefore the best guess of an document might not be above the threshold anymore. Such best " "guesses are listed here.") - super(ChangedThresholdPositionToBelowList, self).__init__("Moved below threshold:", tooltip_text) - - def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]): - return list(filter(lambda update: (update.old_position != update.new_position and - update.new_position == ThresholdPosition.BELOW), - threshold_updates)) + super(ChangedThresholdPositionToBelowList, self).__init__("Moved below threshold:", + tooltip_text, + ThresholdPosition.BELOW) class DataInsightsArea: + """ + Abstract superclass responsible for the common logic required for both, the simple and the extended version of the + Data Insights section. + It only handles the 3D-Grid which is present in both Data Insight section types. + """ + def __init__(self): + """ + Initializes the Data Insight section by initializing the 3D Grid. + """ + + # Init 3D-Grid self.suggestion_visualizer = EmbeddingVisualizerWindow([ (AccessibleColor(visualizations.WHITE, visualizations.WHITE), 'Below threshold'), (AccessibleColor(visualizations.RED, visualizations.ACC_RED), 'Above threshold'), (AccessibleColor(visualizations.GREEN, visualizations.ACC_GREEN), 'Confirmed match') ]) + # Init and setup button responsible for opening the 3D Grid self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") self.suggestion_visualizer_button.setContentsMargins(0, 0, 0, 0) self.suggestion_visualizer_button.setFont(BUTTON_FONT) @@ -188,6 +296,14 @@ def disable_accessible_color_palette(self): class ExtendedDataInsightsArea(QWidget, DataInsightsArea): + """ + Class realizing the extended Data Insights section displayed in the document overview screen and providing + information about the effects of the user's latest feedback. + + It contains a label indicating the current threshold, lists providing nugget related changes due to the user's last + feedback and 3D-Grid displaying the embeddings of all best guesses of all documetns. + """ + def __init__(self): QWidget.__init__(self) DataInsightsArea.__init__(self) From f01f83d58b55fba0ce044bf6eb22ff26b9fa9602 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sat, 28 Sep 2024 10:54:28 +0200 Subject: [PATCH 74/85] add remaining documentation to data_insights.py --- wannadb_ui/data_insights.py | 214 +++++++++++++++++++++++++++++------- 1 file changed, 176 insertions(+), 38 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 09ffe67e..b7c24088 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -68,8 +68,11 @@ def update_list(self, updates: List[UPDATE_TYPE]): Parameters ---------- + updates: List[UPDATE_TYPE] + Items which should be added to the list. """ + # Remove existing items from list self._reset_list() if len(updates) == 0: @@ -111,21 +114,15 @@ def _reset_list(self): class ChangedBestMatchDocumentsList(ChangesList[BestMatchUpdate]): """ - Realizes a ChangesList displaying changed best matches after each user feedback which can be found within the - Data Insights section. - - Methods - ------ - update_list(self, updates: List[UPDATE_TYPE]): - see `update_list` of `ChangesList` + Realizes a `ChangesList` displaying changed best matches after each user feedback which can be found within the + Data Insights section by inheriting from `ChangesList`. """ - def __init__(self, addressed_change: ThresholdPosition): + def __init__(self): """ Determines its tooltip text and name and initializes itself by calling super constructor. """ - self._addressed_change = addressed_change tooltip_text = ("The distance associated with each nugget is recomputed after every feedback round.\n" "Therefore the best guess of an document (nugget with lowest distance) might change " "after a feedback round. Such best guesses are listed here.") @@ -144,7 +141,13 @@ def _create_label_and_tooltip_text(self, update: BestMatchUpdate) -> Tuple[str, class ChangedThresholdPositionList(ChangesList[ThresholdPositionUpdate]): """ Realizes an abstract ChangesList displaying nuggets whose threshold position changed (either above or below) due to - the latest user feedback which can be found in the Data Insights section. + the latest user feedback which can be found in the Data Insights section by inheriting from `ChangesList`. + + Methods + ------- + update_list(updates: List[ThresholdPositionUpdate]) + Extracts the relevant updates out of the given list matching and updates the list with the extracted, relevant + updates. """ def __init__(self, info_label_text: str, tooltip_text: str, addressed_change: ThresholdPosition): @@ -158,8 +161,7 @@ def __init__(self, info_label_text: str, tooltip_text: str, addressed_change: Th tooltip_text: QColor Text further explaining the list's intention displayed if hovering over list's name addressed_change - Determines about which threshold position updates this list cares, either from above to below or below to - above. + Determines the change type addressed by this list, either from above to below or below to above. The given position refers to the end position of the relevant updates (E.g. If it's 'below', then this list only cares about 'above' -> 'below' updates). """ @@ -170,13 +172,29 @@ def __init__(self, info_label_text: str, tooltip_text: str, addressed_change: Th def update_list(self, threshold_updates: List[ThresholdPositionUpdate]): """ - Extracts the relevant updates out of the given list matching + Extracts the relevant updates out of the given list matching and updates the list with the extracted, relevant + updates. + + The given list contains all updates covering changes from above to below as well as below to above the + threshold while this list should only display one of these type of changes. + Therefore, the mentioned extraction is required. + + Parameters + ---------- + threshold_updates: List[ThresholdPositionUpdate] + List of items in which the items to be added can be found. To extract the items to be added from the whole + list, filter it according to the change type addressed by the list. """ + + # Extract relevant updates relevant_updates = self._extract_relevant_updates(threshold_updates) + # Add extracted updates to list super().update_list(relevant_updates) def _create_label_and_tooltip_text(self, update: ThresholdPositionUpdate) -> Tuple[str, str]: + # Computes the label representing one change in the list and the corresponding tooltip + moving_direction = update.new_position.name.lower() label_text = f"{update.best_guess} {'(' + str(update.count) + ')' if update.count > 1 else ''}" @@ -189,6 +207,9 @@ def _create_label_and_tooltip_text(self, update: ThresholdPositionUpdate) -> Tup return label_text, tooltip_text def _extract_relevant_updates(self, threshold_updates: List[ThresholdPositionUpdate]) -> List[ThresholdPositionUpdate]: + # Extracts the updates relevant to this list from a list containing all updates by filtering according to the + # value of `_addressed_change`. + return list(filter(lambda update: (update.old_position != update.new_position and update.new_position == self._addressed_change), threshold_updates)) @@ -237,13 +258,21 @@ def __init__(self): class DataInsightsArea: """ Abstract superclass responsible for the common logic required for both, the simple and the extended version of the - Data Insights section. - It only handles the 3D-Grid which is present in both Data Insight section types. + Data Insights area. + It only handles the 3D-Grid as it's the only component present in both Data Insight area types. + + Methods + ------- + enable_accessible_color_palette() + Enables the accessible color palette in the grid. + disable_accessible_color_palette() + Disables the accessible color palette in the grid. """ def __init__(self): """ - Initializes the Data Insight section by initializing the 3D Grid. + Initializes the Data Insight section by initializing the 3D Grid and setting up the corresponding buttons + responsible for opening the grid. """ # Init 3D-Grid @@ -260,63 +289,113 @@ def __init__(self): self.suggestion_visualizer_button.setMaximumWidth(240) self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) - @track_button_click("Show Suggestions In 3D-Grid") - def _show_suggestion_visualizer(self): - self.suggestion_visualizer.setVisible(True) + def enable_accessible_color_palette(self): + """ + Enables the accessible color palette in the grid. + + For further details, check the related method in `EmbeddingVisualizer`. + """ - def _enable_accessible_color_palette(self): - self.accessible_color_palette = True self.suggestion_visualizer.enable_accessible_color_palette_() - def _disable_accessible_color_palette(self): - self.accessible_color_palette = False + def disable_accessible_color_palette(self): + """ + Disables the accessible color palette in the grid. + + For further details, check the related method in `EmbeddingVisualizer`. + """ + self.suggestion_visualizer.disable_accessible_color_palette_() + @track_button_click("Show Suggestions In 3D-Grid") + def _show_suggestion_visualizer(self): + # Opens the 3D-Grid and tracks the click on the corresponding button + + self.suggestion_visualizer.setVisible(True) + class SimpleDataInsightsArea(QWidget, DataInsightsArea): + """ + Class realizing the simple version of the Data Insights Area which only contains the 3D-Grid with the best guesses + of all best guesses. + + It can be found in the document overview screen if only Level 1 visualization are enabled via the menu. + + Inherits from `QWidget` and `DataInsightsArea`. + """ + def __init__(self): + + # Call super constructors QWidget.__init__(self) DataInsightsArea.__init__(self) + # Set up layout self.layout = QHBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) + # Add button to widget self.layout.addWidget(self.suggestion_visualizer_button, 0, Qt.AlignmentFlag.AlignRight) + # Make itself invisible initially self.setVisible(False) - def enable_accessible_color_palette(self): - self.accessible_color_palette = True - self._enable_accessible_color_palette() - - def disable_accessible_color_palette(self): - self.accessible_color_palette = False - self._disable_accessible_color_palette() - class ExtendedDataInsightsArea(QWidget, DataInsightsArea): """ - Class realizing the extended Data Insights section displayed in the document overview screen and providing - information about the effects of the user's latest feedback. + Class realizing the extended Data Insights section providing + information about the effects of the user's latest feedback as well as a 3D grid displaying all best guesses of all + documents. It contains a label indicating the current threshold, lists providing nugget related changes due to the user's last - feedback and 3D-Grid displaying the embeddings of all best guesses of all documetns. + feedback and 3D-Grid displaying the embeddings of all best guesses of all documents. + The lists providing information about nugget related changes cover two lists displaying nugget whose position + relative to the threshold changed. One list for all "above -> below" changes and one list for all "below -> above" + changes. The lists are realized by utilizing instances of `ChangedThresholdPositionList`. + Furthermore, there's a list displaying all nuggets who newly became the best guess due to the user's latest + feedback. + + It can be found in the document overview screen if Level 2 visualization are enabled via the menu. + + Methods + ------- + update_threshold_value_label(new_threshold_value, threshold_value_change) + Updates the label indicating the current threshold with the given, new value and adds a label indicating the + change of the threshold by considering the given value change. + update_threshold_position_lists(threshold_position_updates: List[ThresholdPositionUpdate]) + Updates the lists displaying nuggets whose position relative to the threshold changed due to the user's latest + feedback by the given list of changes. + update_best_match_list(new_best_matches: List[BestMatchUpdate]) + Updates the list displaying changed best guesses by the given list of changes. + hide() + Hides itself as well as the possibly opened 3D-Grid. """ def __init__(self): + """ + Initializes an instance of this class by calling the related super constructors and setting up the required UI + components. + Setting up the required UI components covers the title displayed above the area, the label indicating the + current threshold and the lists showing changes due to the user's latest feedback. + """ + + # Call super constructors QWidget.__init__(self) DataInsightsArea.__init__(self) + # Set up layout self.layout = QVBoxLayout(self) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) + # Set up title self.title_label = QLabel("Data Insights") self.title_label.setFont(SUBHEADER_FONT) self.title_label.setContentsMargins(0, 5, 0, 5) self.layout.addWidget(self.title_label) + # Set up label indicating the current threshold and a possible change of the threshold's value self.threshold_label = QLabel() self.threshold_label.setFont(LABEL_FONT) self.threshold_label.setText("Current Threshold: ") @@ -333,6 +412,7 @@ def __init__(self): self.threshold_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) self.layout.addLayout(self.threshold_hbox) + # Set up list displaying nuggets whose position relative to the threshold changed from above to below self.changes_list1_hbox = QHBoxLayout() self.changes_list1_hbox.setContentsMargins(0, 0, 0, 0) self.changes_list1_hbox.setSpacing(0) @@ -340,6 +420,7 @@ def __init__(self): self.changes_list1_hbox.addWidget(self.threshold_position_changes_below_list) self.changes_list1_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + # Set up list displaying nuggets whose position relative to the threshold changed from below to above self.changes_list2_hbox = QHBoxLayout() self.changes_list2_hbox.setContentsMargins(0, 0, 0, 0) self.changes_list2_hbox.setSpacing(0) @@ -347,6 +428,7 @@ def __init__(self): self.changes_list2_hbox.addWidget(self.threshold_position_changes_above_list) self.changes_list2_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + # Set up list displaying changed best guesses self.changes_list3_hbox = QHBoxLayout() self.changes_list3_hbox.setContentsMargins(0, 0, 0, 0) self.changes_list3_hbox.setSpacing(0) @@ -360,11 +442,11 @@ def __init__(self): self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) self.changes_best_matches_list = ChangedBestMatchDocumentsList() - self.changes_list3_hbox.addWidget(self.changes_best_matches_list) self.changes_list3_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) self.changes_list3_hbox.addWidget(self.suggestion_visualizer_button) + # Add lists to layout self.layout.addLayout(self.changes_list1_hbox) self.layout.addLayout(self.changes_list2_hbox) self.layout.addLayout(self.changes_list3_hbox) @@ -382,6 +464,24 @@ def disable_accessible_color_palette(self): def update_threshold_value_label(self, new_threshold_value, threshold_value_change): + """ + Updates the label indicating the current threshold with the given, new value and adds a label indicating the + change of the threshold by considering the given value change. + + The text of the label indicates the current threshold is set to the given new value. + If the given value change is non-zero, a label indicating this value change is added next to the label + displaying the actual threshold value. + + Parameters + ---------- + new_threshold_value: float + New threshold value used to update the label indicating the current threshold value. + threshold_value_change: float + Value that indicates how much the threshold has changed compared to the previous one. If non-zero, a label + containing this change is added. + """ + + # Add label indicating the value change if necessary if round(threshold_value_change, 4) != 0: self.threshold_value_label.setStyleSheet("color: yellow;") change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' @@ -390,16 +490,54 @@ def update_threshold_value_label(self, new_threshold_value, threshold_value_chan self.threshold_value_label.setStyleSheet("") self.threshold_change_label.setText("") + # Update the label displaying the current threshold self.threshold_value_label.setText(f"{round(new_threshold_value, 4)} ") self.threshold_label.setVisible(True) - def update_best_match_list(self, new_best_matches: List[BestMatchUpdate]): - self.changes_best_matches_list.update_list(new_best_matches) - def update_threshold_position_lists(self, threshold_position_updates: List[ThresholdPositionUpdate]): + """ + Updates the lists displaying nuggets whose position relative to the threshold changed due to the user's latest + feedback by the given list of changes. + + Each list will extract the relevant changes out of the given list and update itself according to extracted + changes. + + Realized by calling `update_list(updates: List[ThresholdPositionUpdate])` method of + `ChangedThresholdPositionList` for both instances of the lists displaying the threshold position updates. + Further details can be found in the documentation of this method in the `ChangedThresholdPositionList` class. + + Parameters + ---------- + threshold_position_updates: List[ThresholdPositionUpdate] + List containing all nuggets whose position relative to the threshold changed due to the user's latest + feedback. + The list contains both types of changes 'above -> below' and 'below -> above'. + """ + self.threshold_position_changes_below_list.update_list(threshold_position_updates) self.threshold_position_changes_above_list.update_list(threshold_position_updates) + def update_best_match_list(self, new_best_matches: List[BestMatchUpdate]): + """ + Updates the list displaying changed best guesses by the given list of changes. + + Realized by calling `update_list(updates: List[BestMatchUpdate])` method of `ChangedBestMatchList` for the + instance representing the list. + Further details can be found in the documentation of this method in the `ChangedBestMatchList` class. + + Parameters + ---------- + new_best_matches: List[BestMatchUpdate] + List containing changed best guesses by the given list of changes. The `ChangedBestMatchList` instance will + update itself by this list. + """ + + self.changes_best_matches_list.update_list(new_best_matches) + def hide(self): + """ + Hides itself as well as the possibly opened 3D-Grid. + """ + super().hide() self.suggestion_visualizer.hide() From 7071bc9ab5636adbea4705b424c14671b32a8405 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Sun, 29 Sep 2024 18:14:06 +0200 Subject: [PATCH 75/85] add further documentation --- wannadb/change_captor.py | 220 ++++++++++++++++++++++++++++++++++++ wannadb/models.py | 125 -------------------- wannadb/utils.py | 66 ++++++++++- wannadb_ui/data_insights.py | 11 +- 4 files changed, 287 insertions(+), 135 deletions(-) create mode 100644 wannadb/change_captor.py delete mode 100644 wannadb/models.py diff --git a/wannadb/change_captor.py b/wannadb/change_captor.py new file mode 100644 index 00000000..71b060ce --- /dev/null +++ b/wannadb/change_captor.py @@ -0,0 +1,220 @@ +""" +Class providing model classes which can be utilized to capture the changes due a user feedback and propagate them to the +UI. +These changes are computed after every feedback of the user. +""" + +from typing import Optional, Union, List + +from PyQt6.QtGui import QColor + +from wannadb.data.data import InformationNugget +from wannadb_ui.common import ThresholdPosition, AddedReason + + +class BestMatchUpdate: + """ + Instances of this class represent an update of the best match of a document. + + Each instance provide the old best match and the new best match of a document as well as the count specifying how + often similar changes of best guesses happened. + Another best match change is considered as similar if it happened in the same feedback round and the new best guess + is equal. + + Methods + ------- + old_best_match() + Returns the old best match of the related document. + new_best_match() + Returns the new best match of the related document. + count() + Returns the count of similar best match changes happened in the same feedback round. + """ + + def __init__(self, old_best_match: str, new_best_match: str, count: int): + """ + Parameters + ---------- + old_best_match: str + The old best match of the related document. + new_best_match: str + The new best match of the related document. + count: int + The count of similar best match changes happened in the same feedback round. + """ + + self._old_best_match: str = old_best_match + self._new_best_match: str = new_best_match + self._count: int = count + + @property + def old_best_match(self) -> str: + return self._old_best_match + + @property + def new_best_match(self) -> str: + return self._new_best_match + + @property + def count(self) -> int: + return self._count + + +class ThresholdPositionUpdate: + """ + Instances of this class represent an update of the position of a nugget's distance relative to the threshold. + + Each instance provide the text of the nugget whose position changed, the old position (above or below), the new + position (above or below), the old and new distance of the nugget as well as a count indicating how often similar + changes happened in the same feedback round. + A change is considered as similar if it happened in the same feedback round, the text represented by the nugget is + equal, and it has the same type of the update (above -> below or below -> above). + + As mentioned, an instance of this class can cover multiple changes if the text of the nuggets with a change are + equal. + In this case the distance related properties are None as we don't refer to a single nugget. + """ + + def __init__(self, + nugget_text: str, + old_position: Optional[ThresholdPosition], new_position: ThresholdPosition, + old_distance: Optional[float], new_distance: Optional[float], + count: int): + """ + Parameters + ---------- + nugget_text: str + Text of the nuggets whose position relative to the threshold changed. + old_position: ThresholdPosition + Previous position of the covered nuggets relative to the threshold (above or below). + new_position: ThresholdPosition + New position of the covered nuggets relative to the threshold (above or below). + old_distance: float + Old distance associated with the nugget. If multiple nuggets are covered by this instance, this will be + None. + new_distance: float + New distance associated with the nugget. If multiple nuggets are covered by this instance, this will be + None. + count: int + Number of similar changes happened in the same feedback round. + """ + + self._best_guess: str = nugget_text + self._old_position: Optional[ThresholdPosition] = old_position + self._new_position: ThresholdPosition = new_position + self._old_distance: float = old_distance + self._new_distance: float = new_distance + self._count: int = count + + @property + def nugget_text(self) -> str: + return self._best_guess + + @property + def old_position(self) -> Optional[ThresholdPosition]: + return self._old_position + + @property + def new_position(self) -> ThresholdPosition: + return self._new_position + + @property + def old_distance(self) -> Optional[float]: + return self._old_distance + + @property + def new_distance(self) -> Optional[float]: + return self._new_distance + + @property + def count(self) -> int: + return self._count + + +class NewlyAddedNuggetContext: + """ + Instances of this class represent a newly added nugget to the document overview. + Each instance provide information about the old and new distance of the nugget as well as the reason why the system + newly added the nugget. + """ + + def __init__(self, + nugget: InformationNugget, + old_distance: Union[float, None], + new_distance: float, + added_reason: AddedReason): + """ + Parameters + ---------- + nugget: InformationNugget + Newly added nugget. + old_distance: float + Old distance associated with the nugget. + new_distance: float + New distance associated with the nugget. + added_reason: AddedReason + Reason for the nugget being newly added. + """ + + self._nugget = nugget + self._old_distance = old_distance + self._new_distance = new_distance + self._added_reason = added_reason + + @property + def nugget(self): + return self._nugget + + @property + def old_distance(self): + return self._old_distance + + @property + def new_distance(self): + return self._new_distance + + @property + def added_reason(self): + return self._added_reason + + +class NuggetUpdatesContext: + """ + Wrapper class wrapping multiple types of nugget related updates. + Nugget related updates refer to `NewlyAddedNuggetContext`, `ThresholdPositionUpdate` and `BestMatchUpdate`. Each + instance holds a list of updates for all of these 3 update types. + """ + + def __init__(self, + newly_added_nugget_contexts: List[NewlyAddedNuggetContext], + best_match_updates: List[BestMatchUpdate], + threshold_position_updates: List[ThresholdPositionUpdate]): + """ + Parameters + ---------- + newly_added_nugget_contexts: List[NewlyAddedNuggetContext] + List of all `NewlyAddedNuggetContext` instances wrapped by this instance. + best_match_updates: List[BestMatchUpdate] + List of all `BestMatchUpdate` instances wrapped by this instance. + threshold_position_updates: List[ThresholdPositionUpdate] + List of all `ThresholdPositionUpdate` instances wrapped by this instance. + """ + + self._newly_added_nugget_contexts: List[NewlyAddedNuggetContext] = newly_added_nugget_contexts + self._best_match_updates: List[BestMatchUpdate] = best_match_updates + self._threshold_position_updates: List[ThresholdPositionUpdate] = threshold_position_updates + + @property + def newly_added_nugget_contexts(self) -> List[NewlyAddedNuggetContext]: + return self._newly_added_nugget_contexts + + @property + def best_match_updates(self) -> List[BestMatchUpdate]: + return self._best_match_updates + + @property + def threshold_position_updates(self) -> List[ThresholdPositionUpdate]: + return self._threshold_position_updates + + + diff --git a/wannadb/models.py b/wannadb/models.py deleted file mode 100644 index 958a7953..00000000 --- a/wannadb/models.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import Optional, Union, List - -from PyQt6.QtGui import QColor - -from wannadb.data.data import InformationNugget -from wannadb_ui.common import ThresholdPosition, AddedReason - - -class BestMatchUpdate: - def __init__(self, old_best_match: str, new_best_match: str, count: int): - self._old_best_match: str = old_best_match - self._new_best_match: str = new_best_match - self._count: int = count - - @property - def old_best_match(self) -> str: - return self._old_best_match - - @property - def new_best_match(self) -> str: - return self._new_best_match - - @property - def count(self) -> int: - return self._count - - -class ThresholdPositionUpdate: - def __init__(self, best_guess: str, - old_position: Optional[ThresholdPosition], new_position: ThresholdPosition, - old_distance: Optional[float], new_distance: float, - count: int): - self._best_guess: str = best_guess - self._old_position: Optional[ThresholdPosition] = old_position - self._new_position: ThresholdPosition = new_position - self._old_distance: float = old_distance - self._new_distance: float = new_distance - self._count: int = count - - @property - def best_guess(self) -> str: - return self._best_guess - - @property - def old_position(self) -> Optional[ThresholdPosition]: - return self._old_position - - @property - def new_position(self) -> ThresholdPosition: - return self._new_position - - @property - def old_distance(self) -> Optional[float]: - return self._old_distance - - @property - def new_distance(self) -> float: - return self._new_distance - - @property - def count(self) -> int: - return self._count - - -class NewlyAddedNuggetContext: - def __init__(self, nugget: InformationNugget, - old_distance: Union[float, None], - new_distance: float, - added_reason: AddedReason): - self._nugget = nugget - self._old_distance = old_distance - self._new_distance = new_distance - self._added_reason = added_reason - - @property - def nugget(self): - return self._nugget - - @property - def old_distance(self): - return self._old_distance - - @property - def new_distance(self): - return self._new_distance - - @property - def added_reason(self): - return self._added_reason - - -class NuggetUpdatesContext: - def __init__(self, - newly_added_nugget_contexts: List[NewlyAddedNuggetContext], - best_match_updates: List[BestMatchUpdate], - threshold_position_updates: List[ThresholdPositionUpdate]): - self._newly_added_nugget_contexts: List[NewlyAddedNuggetContext] = newly_added_nugget_contexts - self._best_match_updates: List[BestMatchUpdate] = best_match_updates - self._threshold_position_updates: List[ThresholdPositionUpdate] = threshold_position_updates - - @property - def newly_added_nugget_contexts(self) -> List[NewlyAddedNuggetContext]: - return self._newly_added_nugget_contexts - - @property - def best_match_updates(self) -> List[BestMatchUpdate]: - return self._best_match_updates - - @property - def threshold_position_updates(self) -> List[ThresholdPositionUpdate]: - return self._threshold_position_updates - - -class AccessibleColor: - def __init__(self, color: QColor, corresponding_accessible_color: QColor): - self._color = color - self._corresponding_accessible_color = corresponding_accessible_color - - @property - def color(self): - return self._color - - @property - def corresponding_accessible_color(self): - return self._corresponding_accessible_color \ No newline at end of file diff --git a/wannadb/utils.py b/wannadb/utils.py index f24d1058..2171e599 100644 --- a/wannadb/utils.py +++ b/wannadb/utils.py @@ -1,9 +1,22 @@ +""" +Utility class providing common functionality. +""" + import math import numpy as np +from PyQt6.QtGui import QColor def get_possible_duplicate(nugget_to_check, nugget_list): + """ + Checks the given list for duplicates of the given nugget and returns the first occurring duplicate if present and + its index in the list. + + The check whether a nugget duplicates another is realized by the `duplicates(other) -> bool` function of the + `InformationNugget` class. + """ + for idx, nugget in enumerate(nugget_list): if nugget_to_check.duplicates(nugget): return nugget, idx @@ -11,12 +24,27 @@ def get_possible_duplicate(nugget_to_check, nugget_list): return None, None -def positions_equal(pos1: np.ndarray, pos2: np.ndarray) -> bool: - if pos1.shape != (1, 3) or pos2.shape != (1, 3): +def positions_equal(position1: np.ndarray, position2: np.ndarray) -> bool: + """ + Checks if the given arrays are equal meaning that each element of the first array is close enough to the + corresponding value in the second array. + The check for closeness is realized by `math.isclose(...)` function. + + Handles only (1, 3) shaped arrays as this function should only be used for arrays representing 3-dimensional + positions. + If one of the given arrays doesn't conform to this shape, the function returns `False`. + + Returns + ------- + Whether the given arrays are considered as equal according to the explanation above. + """ + + if position1.shape != (1, 3) or position2.shape != (1, 3): return False - return (math.isclose(pos1[0][0], pos2[0][0], rel_tol=1e-05, abs_tol=1e-05) and - math.isclose(pos1[0][1], pos2[0][1], rel_tol=1e-05, abs_tol=1e-05) and - math.isclose(pos1[0][2], pos2[0][2], rel_tol=1e-05, abs_tol=1e-05)) + + return (math.isclose(position1[0][0], position2[0][0], rel_tol=1e-05, abs_tol=1e-05) and + math.isclose(position1[0][1], position2[0][1], rel_tol=1e-05, abs_tol=1e-05) and + math.isclose(position1[0][2], position2[0][2], rel_tol=1e-05, abs_tol=1e-05)) def embeddings_equal(embedding1: np.ndarray, embedding2: np.ndarray) -> bool: @@ -25,3 +53,31 @@ def embeddings_equal(embedding1: np.ndarray, embedding2: np.ndarray) -> bool: arrays_are_close = np.vectorize(math.isclose) return arrays_are_close(embedding1, embedding2, rel_tol=1e-05, abs_tol=1e-05).all() + + +class AccessibleColor: + """ + Utility model class wrapping a color and its corresponding accessible color that is better understandable by users + suffering from color blindness. + """ + + def __init__(self, color: QColor, corresponding_accessible_color: QColor): + """ + Parameters + ---------- + color: QColor + Color represented by this instance. + corresponding_accessible_color: QColor + Accessible color corresponding to the given standard version of the color. + """ + + self._color = color + self._corresponding_accessible_color = corresponding_accessible_color + + @property + def color(self): + return self._color + + @property + def corresponding_accessible_color(self): + return self._corresponding_accessible_color diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index b7c24088..63b6b486 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -9,7 +9,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSpacerItem, QSizePolicy, QPushButton -from wannadb.models import BestMatchUpdate, ThresholdPositionUpdate, AccessibleColor +from wannadb.change_captor import BestMatchUpdate, ThresholdPositionUpdate, AccessibleColor from wannadb_ui import visualizations from wannadb_ui.common import ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ BUTTON_FONT @@ -197,11 +197,12 @@ def _create_label_and_tooltip_text(self, update: ThresholdPositionUpdate) -> Tup moving_direction = update.new_position.name.lower() - label_text = f"{update.best_guess} {'(' + str(update.count) + ')' if update.count > 1 else ''}" - distance_change_text = f"Old distance: {round(update.old_distance, 4)} -> New distance: {round(update.new_distance, 4)}\n" if update.old_distance is not None \ + label_text = f"{update.nugget_text} {'(' + str(update.count) + ')' if update.count > 1 else ''}" + distance_change_text = f"Old distance: {round(update.old_distance, 4)} -> New distance: {round(update.new_distance, 4)}\n" if update.old_distance \ else f"Initial distance: {round(update.new_distance, 4)}\n" - tooltip_text = (f"Due to your last feedback {update.best_guess} moved {moving_direction} the threshold.\n" - f"{distance_change_text}" + + tooltip_text = (f"Due to your last feedback {update.nugget_text} moved {moving_direction} the threshold.\n" + f"{distance_change_text}" if not update.count > 1 else "" # If update covers multiple nuggets, don't show distance text as the tooltip refers to multiple nuggets in this case f"This happened for {update.count - 1} similar nuggets as well.") return label_text, tooltip_text From 27ab0ca1c7eef2d6be3a13588b30cd0fb66f5b1c Mon Sep 17 00:00:00 2001 From: nils-bz-surface Date: Mon, 30 Sep 2024 00:11:26 +0200 Subject: [PATCH 76/85] add further documentation --- wannadb/matching/matching.py | 40 ++++++++++++++++++++++-------- wannadb_ui/common.py | 31 +++++++++++++++++++++++ wannadb_ui/interactive_matching.py | 10 +++----- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index b0cf19da..4f541c80 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -132,8 +132,8 @@ def _call( logger.info(f"Matching attribute '{attribute.name}'.") start_matching: float = time.time() - self._max_distance = self._default_max_distance - self._old_max_distance = -1 + self._max_distance = self._default_max_distance # Current threshold + self._old_max_distance = -1 # Previous threshold attribute[CurrentThresholdSignal] = CurrentThresholdSignal(self._max_distance) statistics[attribute.name]["max_distances"] = [self._max_distance] statistics[attribute.name]["feedback_durations"] = [] @@ -181,13 +181,13 @@ def _sort_remaining_documents(): # iterative user interactions logger.info("Execute interactive matching.") tik: float = time.time() - self._old_feedback_nuggets: List[InformationNugget] = [] - self._new_nugget_contexts: List[NewlyAddedNuggetContext] = [] + self._old_feedback_nuggets: List[InformationNugget] = [] # All nuggets displayed in the previous feedback round + self._new_nugget_contexts: List[NewlyAddedNuggetContext] = [] # All nuggets newly displayed in the current feedback round num_feedback: int = 0 continue_matching: bool = True - new_best_matches: Counter[str] = Counter[str]() - new_to_old_match: Dict[str, str] = {} - old_distances: Dict[InformationNugget, float] = {} + new_best_matches: Counter[str] = Counter[str]() # New best matches due the user's latest feedback + new_to_old_match: Dict[str, str] = {} # All new best matches mapped to the corresponding previous best match of the same document + old_distances: Dict[InformationNugget, float] = {} # Values of CachedDistanceSignal for all nuggets in previous feedback round while continue_matching and num_feedback < self._max_num_feedback and remaining_documents != []: # sort remaining documents by distance _sort_remaining_documents() @@ -244,6 +244,7 @@ def _sort_remaining_documents(): new_docs = random.choices(remaining_documents[:num_nuggets_above], k=k) selected_documents.extend(new_docs) num_nuggets_above -= k + # Mark best matches of newly added docs as newly added nuggets if they weren't present in previous feedback round self._update_new_nugget_contexts(new_docs, AddedReason.MOST_UNCERTAIN, old_distances) # ... and those that recently got interesting additional extractions to the list if self.num_recent_docs > 0 and len(docs_with_added_nuggets) > 0: @@ -252,6 +253,7 @@ def _sort_remaining_documents(): if len(selected_docs_with_added_nuggets) > self.num_recent_docs: selected_docs_with_added_nuggets = random.choices(selected_docs_with_added_nuggets, k=self.num_recent_docs) selected_documents.extend(selected_docs_with_added_nuggets) + # Mark best matches of newly added docs as newly added nuggets if they weren't present in previous feedback round self._update_new_nugget_contexts(selected_docs_with_added_nuggets, AddedReason.INTERESTING_ADDITIONAL_EXTRACTION, old_distances) @@ -261,6 +263,7 @@ def _sort_remaining_documents(): docs_at_threshold_to_add = [doc for doc in remaining_documents[higher_left:lower_right] if doc not in selected_docs_with_added_nuggets] selected_documents.extend(docs_at_threshold_to_add) + # Mark best matches of selected docs as newly added if they weren't present in previous feedback round self._update_new_nugget_contexts(docs_at_threshold_to_add, AddedReason.AT_THRESHOLD, old_distances) # Sort to unify the order across the different three sources @@ -283,11 +286,14 @@ def _sort_remaining_documents(): t0 = time.time() + # Build all `BestMatchUpdate` instances based on `new_best_matches` dict best_match_updates = [BestMatchUpdate(new_to_old_match[new_best_match], new_best_match, new_best_matches[new_best_match]) for new_best_match in new_best_matches.keys()] + # Build all `ThresholdPositionUpdate` instances based on old and new distances of all nuggets and the current and previous threshold threshold_position_updates = self._compute_threshold_position_updates(document_base, old_distances) + # Gather all update types in `NuggetUpdatesContext` instance which is passed to UI nugget_updates_context = NuggetUpdatesContext(newly_added_nugget_contexts=self._new_nugget_contexts, best_match_updates=best_match_updates, threshold_position_updates=threshold_position_updates) @@ -310,10 +316,12 @@ def _sort_remaining_documents(): t1 = time.time() statistics[attribute.name]["feedback_durations"].append(t1 - t0) + # Reinit all variables providing information related to previous feedback round self._old_max_distance = self._max_distance self._old_feedback_nuggets = feedback_nuggets - self._new_nugget_contexts.clear() old_distances = {nugget: nugget[CachedDistanceSignal] for nugget in document_base.nuggets} + # Reset all variables providing information related to current feedback round + self._new_nugget_contexts.clear() new_best_matches.clear() new_to_old_match.clear() @@ -413,12 +421,13 @@ def run_nugget_pipeline(nuggets): if distances_based_on_label or new_distance < nugget[CachedDistanceSignal]: nugget[CachedDistanceSignal] = new_distance - previous_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] + previous_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] # Save previous best match for ix, nugget in enumerate(document.nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: document[CurrentMatchIndexSignal] = ix new_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] + # If there's new best match, save it and add mapping to previous best match for later use if previous_best_match != new_best_match: new_best_matches.update([new_best_match.text]) new_to_old_match[new_best_match.text] = previous_best_match.text @@ -507,12 +516,13 @@ def run_nugget_pipeline(nuggets): if distances_based_on_label or new_distance < nugget[CachedDistanceSignal]: nugget[CachedDistanceSignal] = new_distance - previous_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] + previous_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] # Save previous best match for ix, nugget in enumerate(document.nuggets): current_guess: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] if nugget[CachedDistanceSignal] < current_guess[CachedDistanceSignal]: document[CurrentMatchIndexSignal] = ix new_best_match: InformationNugget = document.nuggets[document[CurrentMatchIndexSignal]] + # If there's new best match, save it and add mapping to previous best match for later use if previous_best_match != new_best_match: new_best_matches.update([new_best_match.text]) new_to_old_match[new_best_match.text] = previous_best_match.text @@ -595,6 +605,9 @@ def run_nugget_pipeline(nuggets): def _update_new_nugget_contexts(self, new_docs: List[Document], added_reason: AddedReason, old_distances: Dict[InformationNugget, float]): + # Computes the newly added nuggets in this feedback round and creates the corresponding instances wrapping these updates + # To determine whether a nugget is newly added, the method considers the `_old_feedback_nuggets` list + best_matches: List[InformationNugget] = [new_doc.nuggets[new_doc[CurrentMatchIndexSignal]] for new_doc in new_docs] @@ -605,6 +618,12 @@ def _update_new_nugget_contexts(self, new_docs: List[Document], added_reason: Ad for nugget in best_matches if nugget not in self._old_feedback_nuggets]) def _compute_threshold_position_updates(self, document_base, old_distances): + # Computes the nuggets whose position of their distance relative to the threshold changed in this feedback round + # and creates the corresponding instances wrapping these updates. + + # To determine these updates, the method iterates over all best matches and considers the old distances of these + # nuggets, the old threshold as well as the current distances and threshold to determine their old and new threshold position + threshold_position_updates: Dict[str, Tuple[ThresholdPositionUpdate, Optional[ThresholdPositionUpdate]]] = dict() for nugget in document_base.nuggets: @@ -622,6 +641,7 @@ def _compute_threshold_position_updates(self, document_base, old_distances): new_threshold_position = ThresholdPosition.ABOVE if nugget[CachedDistanceSignal] > self._max_distance \ else ThresholdPosition.BELOW if old_threshold_position != new_threshold_position: + # If old and new threshold position differ, an `ThresholdPositionUpdate` instance is create representing this update if (old_update is not None and old_update.old_position == old_threshold_position and old_update.new_position == new_threshold_position): diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index c4ea8554..5525172c 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -62,6 +62,10 @@ class NuggetUpdateType(Enum): class AddedReason(Enum): + """ + Corresponds to the reason why the framework decided to newly add a nugget to the overview list. + """ + MOST_UNCERTAIN = "The documents match belongs to the considered most uncertain matches." INTERESTING_ADDITIONAL_EXTRACTION = "The document recently got interesting additional extraction to the list." AT_THRESHOLD = "The distance of the guessed match is within the considered range around the threshold." @@ -75,10 +79,32 @@ def corresponding_tooltip_text(self): class VisualizationProvidingItem: + """ + Abstract class identifying UI items which provide any kind of visualization and therefore requires adapting if the + enabled visualization level changes. + Forces classes inheriting from this class to implement a method `_adapt_to_visualizations_level(visualizations_level)` + adapting the UI element according to the currently enabled visualization level. + + Methods + ------- + update_shown_visualizations(visualization_level: AvailableVisualizationsLevel) + Adapts the corresponding UI element to the given visualization level as each level allows different + visualization components to be enabled. + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def update_shown_visualizations(self, visualization_level: AvailableVisualizationsLevel): + """ + Adapts the corresponding UI element to the given visualization level as each level allows different + visualization components to be enabled. + + Parameters + ---------- + visualization_level: AvailableVisualizationsLevel + Visualization level to which the UI component needs to be adapted. + """ self._adapt_to_visualizations_level(visualization_level) @abstractmethod @@ -220,6 +246,11 @@ def _create_new_widget(self): class VisualizationProvidingCustomScrollableList(CustomScrollableList, VisualizationProvidingItem): + """ + Class realizing a `CustomScrollableList` providing visualizations via the items in the list by inheriting from + `CustomScrollableList` and `VisualizationProvidingItem`. + """ + def __init__(self, parent, item_type, visualizations_level, attach_visualization_level_observer, floating_widget=None, orientation="vertical", above_widget=None): super().__init__(parent, item_type, floating_widget, orientation, above_widget) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 15a4500b..d091d24a 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -8,14 +8,12 @@ from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy -from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal, \ - PCADimensionReducedLabelEmbeddingSignal, PCADimensionReducedTextEmbeddingSignal, \ - TSNEDimensionReducedLabelEmbeddingSignal +from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal from wannadb.models import NewlyAddedNuggetContext from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ - CustomScrollableList, CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, \ + CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, \ VisualizationProvidingItem, AvailableVisualizationsLevel, VisualizationProvidingCustomScrollableList -from wannadb_ui.data_insights import DataInsightsArea, SimpleDataInsightsArea, ExtendedDataInsightsArea +from wannadb_ui.data_insights import SimpleDataInsightsArea, ExtendedDataInsightsArea from wannadb_ui.visualizations import EmbeddingVisualizerWidget, BarChartVisualizerWidget from wannadb_ui.study import Tracker, track_button_click @@ -79,7 +77,7 @@ def show_document_widget(self): self.layout.addWidget(self.document_widget) self.stop_button.hide() - def enable_accessible_color_palette(self): + def enable_accessible_color_palette(self): self.document_widget.enable_accessible_color_palette() self.nugget_list_widget.enable_accessible_color_palette() From 4afb9bfa49a3d448c06199ada60be64f7e8cdb82 Mon Sep 17 00:00:00 2001 From: nils-bz Date: Mon, 30 Sep 2024 00:34:59 +0200 Subject: [PATCH 77/85] add further documentation --- wannadb/matching/matching.py | 19 +++-- wannadb_ui/common.py | 128 ++++++++++++++++++++++------- wannadb_ui/data_insights.py | 3 +- wannadb_ui/interactive_matching.py | 4 +- wannadb_ui/visualizations.py | 2 +- 5 files changed, 114 insertions(+), 42 deletions(-) diff --git a/wannadb/matching/matching.py b/wannadb/matching/matching.py index 4f541c80..0fe2df05 100644 --- a/wannadb/matching/matching.py +++ b/wannadb/matching/matching.py @@ -13,7 +13,7 @@ from wannadb.interaction import BaseInteractionCallback from wannadb.matching.custom_match_extraction import BaseCustomMatchExtractor from wannadb.matching.distance import BaseDistance -from wannadb.models import NewlyAddedNuggetContext, NuggetUpdatesContext, BestMatchUpdate, ThresholdPositionUpdate +from wannadb.change_captor import NewlyAddedNuggetContext, NuggetUpdatesContext, BestMatchUpdate, ThresholdPositionUpdate from wannadb.statistics import Statistics from wannadb.status import BaseStatusCallback from wannadb_ui.common import AddedReason, ThresholdPosition @@ -618,21 +618,19 @@ def _update_new_nugget_contexts(self, new_docs: List[Document], added_reason: Ad for nugget in best_matches if nugget not in self._old_feedback_nuggets]) def _compute_threshold_position_updates(self, document_base, old_distances): - # Computes the nuggets whose position of their distance relative to the threshold changed in this feedback round - # and creates the corresponding instances wrapping these updates. - - # To determine these updates, the method iterates over all best matches and considers the old distances of these - # nuggets, the old threshold as well as the current distances and threshold to determine their old and new threshold position - + # Computes all threshold position updates of the current feedback round based on the old and new distances of the nuggets as well as the old and new threshold threshold_position_updates: Dict[str, Tuple[ThresholdPositionUpdate, Optional[ThresholdPositionUpdate]]] = dict() for nugget in document_base.nuggets: + # We only care about nuggets representing a current best guesses is_best_guess = nugget.document.nuggets[nugget.document[CurrentMatchIndexSignal]].text == nugget.text if not is_best_guess: continue + # Since we map the nuggets text to the corresponding update and there can be nuggets with equal texts, there can already be updates created for the current nugget's text old_update = threshold_position_updates[nugget.text][0] if nugget.text in threshold_position_updates else None + # Compute old and new threshold position of the current nugget if self._old_max_distance == -1: old_threshold_position = None else: @@ -640,8 +638,10 @@ def _compute_threshold_position_updates(self, document_base, old_distances): else ThresholdPosition.BELOW new_threshold_position = ThresholdPosition.ABOVE if nugget[CachedDistanceSignal] > self._max_distance \ else ThresholdPosition.BELOW + + # Create update instances if old and new position differ if old_threshold_position != new_threshold_position: - # If old and new threshold position differ, an `ThresholdPositionUpdate` instance is create representing this update + # If there's already a similar update created for the text of the current nugget, replace it by new one and increment its counter by one if (old_update is not None and old_update.old_position == old_threshold_position and old_update.new_position == new_threshold_position): @@ -652,6 +652,7 @@ def _compute_threshold_position_updates(self, document_base, old_distances): nugget[CachedDistanceSignal], old_update.count + 1), None) + # If there's already an update present whose type (above -> below / below -> above) is different, create new update and keep old one elif old_update is not None: threshold_position_updates[nugget.text] = (old_update, ThresholdPositionUpdate(nugget.text, @@ -660,6 +661,7 @@ def _compute_threshold_position_updates(self, document_base, old_distances): old_distances[nugget] if nugget in old_distances else None, nugget[CachedDistanceSignal], 1)) + # If there's no update present for the text of the current nugget, just create new one else: threshold_position_updates[nugget.text] = (ThresholdPositionUpdate(nugget.text, old_threshold_position, @@ -669,6 +671,7 @@ def _compute_threshold_position_updates(self, document_base, old_distances): 1), None) + # Create final result by concatenating all created updates result = [] for first_update, second_update in threshold_position_updates.values(): if second_update is None: diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 5525172c..60dc7ff2 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -314,11 +314,31 @@ def show_confirmation_dialog(parent, title_text, explanation_text, accept_text, class InformationPopup(QMainWindow): + """ + Realizes an information popup as a separate window. + + The content to be displayed within the window is determined by a markdown file. + """ + def __init__(self, title: str, content_file_to_display: str): + """ + Initializes an instance of this class by reading the given markdown file and render the corresponding content + in the window. + + Parameters + ---------- + title: str + The title of the popup. + content_file_to_display: str + The path to the markdown file determining the content to be displayed. + """ + super().__init__() + # Init widget containing the HTML content defined in the given markdown file self._text_widget = QTextEdit() + # Read markdown file with open(content_file_to_display, "r") as file: formatted_text = file.read() markdown_result = markdown.markdown(formatted_text) @@ -332,7 +352,28 @@ def __init__(self, title: str, content_file_to_display: str): class InfoDialog(QDialog): + """ + Realizes an information dialog in form of pop-ups. + + The user can click through the dialog via navigation buttons or skip the whole dialog directly. + + Methods + ------- + set_info_list(info_list) + Sets the information to display within this dialog. + set_image_list(image_list) + Sets the images to display within the dialogs. + exec() + Overwrite `exec()` to avoid multiple executions. + """ + def __init__(self): + """ + Initializes the required UI components. + The UI consists of the raw information text, an image serving as illustration as well as navigation buttons to + open next/previous screen or skip the dialog. + """ + super().__init__() self.dialog_shown: bool = False @@ -360,15 +401,15 @@ def __init__(self): self.button_layout = QHBoxLayout() self.prev_button = QPushButton("Previous") - self.prev_button.clicked.connect(self.show_previous) + self.prev_button.clicked.connect(self._show_previous) self.button_layout.addWidget(self.prev_button) self.next_button = QPushButton("Next") - self.next_button.clicked.connect(self.show_next) + self.next_button.clicked.connect(self._show_next) self.button_layout.addWidget(self.next_button) self.skip_button = QPushButton("Skip") - self.skip_button.clicked.connect(self.skip) + self.skip_button.clicked.connect(self._skip) self.button_layout.addWidget(self.skip_button) # Add button layout to the main layout @@ -377,25 +418,56 @@ def __init__(self): # Set the layout for the dialog self.setLayout(self.layout) - # Setter method to set the info_list - def set_info_list(self, info_list): + def set_info_list(self, info_list: List[str]): + """ + Sets the information to display within this dialog. + Each element of the given list represents an information text to be displayed in one of the pop-ups opened + during the dialog execution. + + Parameters + ---------- + info_list: List[str] + Determines the information to display within the dialog. + """ + self.info_list = info_list - self.update_info() + self._update_info() + + def set_image_list(self, image_list: List[str]): + """ + Sets the images to display within this dialog. + Each element of the given list represents a path to an image to be displayed in one of the pop-ups opened + during the dialog execution. + + Parameters + ---------- + image_list: List[str] + Determines the images to display within the dialog. + """ - # Setter method to set the image_list - def set_image_list(self, image_list): self.image_list = image_list - self.update_image() + self._update_image() + + def exec(self): + """ + Overwrite `exec()` to avoid multiple executions. + If dialog is not shown currently, call `exec()` of superclass, else do nothing. + + For more information check documentation of `exec()` in `QtWidgets` module. + """ + if not self.dialog_shown: + super().exec() + self.dialog_shown = True - # Method to update the displayed information - def update_info(self): + def _update_info(self): + # Update the displayed information if self.info_list is not None: self.info_label.setText(self.info_list[self.current_index]) - self.update_image() - self.update_buttons() + self._update_image() + self._update_buttons() - # Method to update the displayed PNG image - def update_image(self): + def _update_image(self): + # Method to update the displayed PNG image if self.image_list is not None: image_path = self.image_list[self.current_index] if image_path and image_path.endswith(".png"): @@ -406,30 +478,24 @@ def update_image(self): self.image_widget.clear() self.image_widget.setVisible(False) - # Method to update the state of the buttons - def update_buttons(self): + def _update_buttons(self): + # Method to update the state of the buttons if self.info_list is not None: self.prev_button.setEnabled(self.current_index > 0) self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) - # Method to show the previous piece of information - def show_previous(self): + def _show_previous(self): + # Method to show the previous piece of information if self.current_index > 0: self.current_index -= 1 - self.update_info() + self._update_info() - # Method to show the next piece of information - def show_next(self): + def _show_next(self): + # Method to show the next piece of information if self.current_index < len(self.info_list) - 1: self.current_index += 1 - self.update_info() + self._update_info() - # Method to skip and close the dialog - def skip(self): + def _skip(self): + # Method to skip and close the dialog self.accept() - - # Override exec to prevent multiple executions - def exec(self): - if not self.dialog_shown: - super().exec() - self.dialog_shown = True diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 63b6b486..a51ea8c9 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -9,7 +9,8 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSpacerItem, QSizePolicy, QPushButton -from wannadb.change_captor import BestMatchUpdate, ThresholdPositionUpdate, AccessibleColor +from wannadb.change_captor import BestMatchUpdate, ThresholdPositionUpdate +from wannadb.utils import AccessibleColor from wannadb_ui import visualizations from wannadb_ui.common import ThresholdPosition, SUBHEADER_FONT, LABEL_FONT, \ BUTTON_FONT diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index d091d24a..568edcdd 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -9,7 +9,7 @@ from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, QGridLayout, QSizePolicy from wannadb.data.signals import CachedContextSentenceSignal, CachedDistanceSignal -from wannadb.models import NewlyAddedNuggetContext +from wannadb.change_captor import NewlyAddedNuggetContext from wannadb_ui.common import BUTTON_FONT, CODE_FONT, CODE_FONT_BOLD, LABEL_FONT, MainWindowContent, \ CustomScrollableListItem, WHITE, LIGHT_YELLOW, YELLOW, \ VisualizationProvidingItem, AvailableVisualizationsLevel, VisualizationProvidingCustomScrollableList @@ -784,6 +784,8 @@ def disable_input(self): pass def _adapt_to_visualizations_level(self, visualizations_level): + # Adapt UI element to enabled visualizations (show or hide certainty label) + if visualizations_level != AvailableVisualizationsLevel.LEVEL_2: self.certainty_label.hide() else: diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 91256525..bb46ff44 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -36,7 +36,7 @@ from wannadb.data.data import InformationNugget, Attribute from wannadb.data.signals import PCADimensionReducedTextEmbeddingSignal, PCADimensionReducedLabelEmbeddingSignal, \ CachedDistanceSignal, CurrentThresholdSignal -from wannadb.models import AccessibleColor +from wannadb.utils import AccessibleColor from wannadb_ui.common import BUTTON_FONT_SMALL, InfoDialog from wannadb_ui.study import Tracker, track_button_click From 1659f95fed9d5dacf02b5b87078368d31edce5c7 Mon Sep 17 00:00:00 2001 From: nils-bz-surface Date: Mon, 30 Sep 2024 23:04:42 +0200 Subject: [PATCH 78/85] fix enabling / disabling of accessible color palette --- wannadb_ui/data_insights.py | 4 ++-- wannadb_ui/interactive_matching.py | 2 +- wannadb_ui/visualizations.py | 22 ++++++++++++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index a51ea8c9..440c8893 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -298,7 +298,7 @@ def enable_accessible_color_palette(self): For further details, check the related method in `EmbeddingVisualizer`. """ - self.suggestion_visualizer.enable_accessible_color_palette_() + self.suggestion_visualizer.enable_accessible_color_palette() def disable_accessible_color_palette(self): """ @@ -307,7 +307,7 @@ def disable_accessible_color_palette(self): For further details, check the related method in `EmbeddingVisualizer`. """ - self.suggestion_visualizer.disable_accessible_color_palette_() + self.suggestion_visualizer.disable_accessible_color_palette() @track_button_click("Show Suggestions In 3D-Grid") def _show_suggestion_visualizer(self): diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 568edcdd..76edea0a 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -709,7 +709,7 @@ def enable_accessible_color_palette(self): self.visualizer.enable_accessible_color_palette() def disable_accessible_color_palette(self): - self.visualizer.enable_accessible_color_palette() + self.visualizer.disable_accessible_color_palette() def show_visualizations(self): self.upper_buttons_widget.show() diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index bb46ff44..6600827f 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -275,7 +275,7 @@ def __init__(self, # Add the given colors with their meanings to the given legend self._update_legend() - def enable_accessible_color_palette_(self): + def enable_accessible_color_palette(self): """ Replaces the colors of points displayed within the grid by accessible colors allowing people with color blindness to better differentiate the colors. @@ -283,8 +283,13 @@ def enable_accessible_color_palette_(self): self._accessible_color_palette = True self._update_legend() + self.update_and_display_params(self._attribute, + self._nuggets, + self._currently_highlighted_nugget, + self._best_guess, + self._other_best_guesses) - def disable_accessible_color_palette_(self): + def disable_accessible_color_palette(self): """ Replaces the colors of points displayed within the grid by the originally used colors and therefore disables the usage of accessible colors. @@ -292,6 +297,11 @@ def disable_accessible_color_palette_(self): self._accessible_color_palette = False self._update_legend() + self.update_and_display_params(self._attribute, + self._nuggets, + self._currently_highlighted_nugget, + self._best_guess, + self._other_best_guesses) def update_and_display_params(self, attribute: Attribute, @@ -782,11 +792,11 @@ def enable_accessible_color_palette(self): """ # Call superclass implementation to enable accessible color palette on this grid - super().enable_accessible_color_palette_() + super().enable_accessible_color_palette() # Enable accessible color palette in fullscreen window if present if self._fullscreen_window is not None: - self._fullscreen_window.enable_accessible_color_palette_() + self._fullscreen_window.enable_accessible_color_palette() def disable_accessible_color_palette(self): """ @@ -799,11 +809,11 @@ def disable_accessible_color_palette(self): """ # Call superclass implementation to disable accessible color palette on this grid - super().disable_accessible_color_palette_() + super().disable_accessible_color_palette() # Disable accessible color palette in fullscreen window if present if self._fullscreen_window is not None: - self._fullscreen_window.disable_accessible_color_palette_() + self._fullscreen_window.disable_accessible_color_palette() def return_from_embedding_visualizer_window(self): """ From ff531455aec48af8523772d82181d46c706a183b Mon Sep 17 00:00:00 2001 From: nils-bz-surface Date: Mon, 30 Sep 2024 23:05:20 +0200 Subject: [PATCH 79/85] change color indicating threshold change and appearance of attribute embedding in grid --- wannadb_ui/data_insights.py | 2 +- wannadb_ui/visualizations.py | 4 ++-- wannadb_ui/wannadb_api.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index 440c8893..dca9e247 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -485,7 +485,7 @@ def update_threshold_value_label(self, new_threshold_value, threshold_value_chan # Add label indicating the value change if necessary if round(threshold_value_change, 4) != 0: - self.threshold_value_label.setStyleSheet("color: yellow;") + self.threshold_value_label.setStyleSheet("color: orange;") change_text = f'(+{round(threshold_value_change, 4)})' if threshold_value_change > 0 else f'{round(threshold_value_change, 4)})' self.threshold_change_label.setText(change_text) else: diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 6600827f..9d534ff4 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -474,8 +474,8 @@ def _display_nugget_embeddings(self, nuggets): annotation_text=_build_nuggets_annotation_text(nugget)) def _display_attribute_embedding(self, attribute): - self._add_item_to_grid(nugget_to_display_context=(attribute, ACC_RED if self._accessible_color_palette else RED), - annotation_text=attribute.name) + self._add_item_to_grid(nugget_to_display_context=(attribute, WHITE), + annotation_text=f'Attribute: {attribute.name}') self._attribute = attribute def _remove_nuggets_from_widget(self, nuggets_to_remove): diff --git a/wannadb_ui/wannadb_api.py b/wannadb_ui/wannadb_api.py index b9370db7..b94bcbcd 100644 --- a/wannadb_ui/wannadb_api.py +++ b/wannadb_ui/wannadb_api.py @@ -14,7 +14,7 @@ from wannadb.matching.custom_match_extraction import FaissSentenceSimilarityExtractor from wannadb.matching.distance import SignalsMeanDistance from wannadb.matching.matching import RankingBasedMatcher -from wannadb.preprocessing.dimension_reduction import PCAReducer, TSNEReducer +from wannadb.preprocessing.dimension_reduction import PCAReducer from wannadb.preprocessing.embedding import BERTContextSentenceEmbedder, RelativePositionEmbedder, \ SBERTTextEmbedder, SBERTLabelEmbedder, SBERTDocumentSentenceEmbedder from wannadb.preprocessing.extraction import StanzaNERExtractor, SpacyNERExtractor From ab09738f69ee791c3115b4725a7f0945b2538a2b Mon Sep 17 00:00:00 2001 From: eneapane Date: Mon, 30 Sep 2024 21:57:06 +0200 Subject: [PATCH 80/85] Add comments study.py --- wannadb_ui/study.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/wannadb_ui/study.py b/wannadb_ui/study.py index af26f4fc..bb050706 100644 --- a/wannadb_ui/study.py +++ b/wannadb_ui/study.py @@ -11,11 +11,13 @@ logger: logging.Logger = logging.getLogger(__name__) +# Singleton class for tracking user interaction with a GUI class Tracker(QObject): _instance = None # Class-level attribute to store the singleton instance time_spent_signal = pyqtSignal(str, float) # Define the signal with window name and time spent def __new__(cls, *args, **kwargs): + """Singleton pattern ensures one instance of the class""" if not cls._instance: cls._instance = super(Tracker, cls).__new__(cls, *args, **kwargs) cls._instance._initialized = False @@ -23,6 +25,7 @@ def __new__(cls, *args, **kwargs): return cls._instance def __init__(self): + """Initialize tracking properties if not already initialized""" if not self._initialized: super().__init__() # Call the QObject initializer self.window_open_times = {} @@ -36,6 +39,9 @@ def __init__(self): self.json_data = [] def dump_report(self): + """Dumps the interaction data to two report files. + One of them contains a json representations of the user activiy, the other + contains natural text.""" log_directory = './logs' log_file = os.path.join(log_directory, 'user_report.txt') os.makedirs(log_directory, exist_ok=True) @@ -61,18 +67,21 @@ def dump_report(self): logger.info(f"Dumped the json report file in {round(tick - tack, 2)} seconds") def start_timer(self, window_name: str): + """Starts the timer for tracking window open time""" self.window_open_times[window_name] = QDateTime.currentDateTime() self.timer.start(1000) self.log += f"{self.sequence_number}. {window_name} was opened\n" - self.json_data.append({'type': 'window', 'action': 'open' ,'identifier': window_name}) + self.json_data.append({'type': 'window', 'action': 'open', 'identifier': window_name}) self.sequence_number += 1 def stop_timer(self, window_name: str): + """Stops the timer for a window and calculates the time spent""" self.timer.stop() logger.debug(f"window_name = {window_name}") self.calculate_time_spent(window_name) def calculate_time_spent(self, window_name: str): + """Calculates the time spent in a window and logs the result""" if self.window_open_times[window_name]: current_time = QDateTime.currentDateTime() time_spent = self.window_open_times[window_name].msecsTo(current_time) / 1000.0 # Convert to seconds @@ -84,15 +93,18 @@ def calculate_time_spent(self, window_name: str): self.total_window_open_times[window_name] = time_spent self.log += f'{self.sequence_number}. {window_name} was closed. Time spent in {window_name} : {round(time_spent, 2)} seconds.\n' self.sequence_number += 1 - self.json_data.append({'type': 'window', 'action': 'close', 'identifier': window_name, 'time_open': time_spent}) + self.json_data.append( + {'type': 'window', 'action': 'close', 'identifier': window_name, 'time_open': time_spent}) def track_button_click(self, button_name: str): + """Tracks button clicks and logs them. Helper method for the decorator below""" self.button_click_counts[button_name] += 1 self.log += f'{self.sequence_number}. {button_name} was clicked.\n' self.sequence_number += 1 self.json_data.append({'type': 'button', 'identifier': button_name}) def track_tooltip_activation(self, tooltip_object: str): + """Tracks tooltip activations and logs them. Must be manually wired to every added tooltip""" self.tooltips_hovered_counts[tooltip_object] += 1 self.log += f'{self.sequence_number}. The following tooltip was activated:\n {tooltip_object} \n' self.sequence_number += 1 @@ -100,6 +112,7 @@ def track_tooltip_activation(self, tooltip_object: str): def track_button_click(button_name: str): + """Decorator to track button clicks. Add to function signature behind a button to start logging""" def decorator(func: Callable): @wraps(func) def wrapper(self, *args, **kwargs): @@ -109,4 +122,5 @@ def wrapper(self, *args, **kwargs): return func(self, *args, **kwargs) return wrapper - return decorator \ No newline at end of file + + return decorator From c3f196b732ae6db5233888be7bd25beb99836241 Mon Sep 17 00:00:00 2001 From: eneapane Date: Mon, 30 Sep 2024 22:39:45 +0200 Subject: [PATCH 81/85] Refactor text --- wannadb_ui/common.py | 132 +++++++++--------- .../info_popups/barchart_tutorial.md | 50 +++++++ wannadb_ui/visualizations.py | 66 +-------- 3 files changed, 119 insertions(+), 129 deletions(-) create mode 100644 wannadb_ui/resources/info_popups/barchart_tutorial.md diff --git a/wannadb_ui/common.py b/wannadb_ui/common.py index 60dc7ff2..3933360c 100644 --- a/wannadb_ui/common.py +++ b/wannadb_ui/common.py @@ -1,3 +1,4 @@ +import os from abc import ABC, abstractmethod from enum import Enum from typing import Union, List, Optional, Tuple @@ -373,7 +374,6 @@ def __init__(self): The UI consists of the raw information text, an image serving as illustration as well as navigation buttons to open next/previous screen or skip the dialog. """ - super().__init__() self.dialog_shown: bool = False @@ -381,21 +381,18 @@ def __init__(self): self.info_list = None self.image_list = None self.current_index = 0 + self.base_dir = "" # Base directory to prepend to image paths # Set up the dialog layout self.layout = QVBoxLayout() # Set a fixed width for the dialog - self.setFixedWidth(400) # Set the fixed width you prefer - - # Label to display the information text - self.info_label = QLabel() - self.info_label.setWordWrap(True) # Enable word wrap for the label - self.layout.addWidget(self.info_label) + self.setFixedWidth(600) - # Widget to display the PNG image - self.image_widget = QLabel() - self.layout.addWidget(self.image_widget) + # Text edit to display the information text (supports HTML) + self.info_text = QTextEdit() + self.info_text.setReadOnly(True) # Make sure the text is not editable + self.layout.addWidget(self.info_text) # Buttons for navigation (Previous, Next, Skip) self.button_layout = QHBoxLayout() @@ -418,84 +415,89 @@ def __init__(self): # Set the layout for the dialog self.setLayout(self.layout) - def set_info_list(self, info_list: List[str]): + def load_markdown_file(self, file_path: str): """ - Sets the information to display within this dialog. - Each element of the given list represents an information text to be displayed in one of the pop-ups opened - during the dialog execution. - - Parameters - ---------- - info_list: List[str] - Determines the information to display within the dialog. + Load the markdown file and convert it to HTML. """ + self.base_dir = os.path.dirname(os.path.abspath(file_path)) # Store base directory of the markdown file - self.info_list = info_list - self._update_info() + with open(file_path, 'r', encoding='utf-8') as file: + markdown_content = file.read() + # Use custom delimiter to split sections instead of
    + sections = markdown_content.split('') + # Convert each section to HTML and store in info_list + self.info_list = [markdown.markdown(section) for section in sections] - def set_image_list(self, image_list: List[str]): - """ - Sets the images to display within this dialog. - Each element of the given list represents a path to an image to be displayed in one of the pop-ups opened - during the dialog execution. + self._update_info() - Parameters - ---------- - image_list: List[str] - Determines the images to display within the dialog. + def _update_info(self): """ - - self.image_list = image_list - self._update_image() - - def exec(self): + Updates the displayed information in the QTextEdit widget. + Handles switching between sections. """ - Overwrite `exec()` to avoid multiple executions. - If dialog is not shown currently, call `exec()` of superclass, else do nothing. + if self.info_list is not None: + # Add base path to image sources in the HTML + html_with_images = self._add_base_path_to_images(self.info_list[self.current_index]) + self.info_text.setHtml(html_with_images) + self._update_buttons() - For more information check documentation of `exec()` in `QtWidgets` module. + def _add_base_path_to_images(self, html: str) -> str: """ - if not self.dialog_shown: - super().exec() - self.dialog_shown = True + Modify HTML content to prepend base directory to image sources. - def _update_info(self): - # Update the displayed information - if self.info_list is not None: - self.info_label.setText(self.info_list[self.current_index]) - self._update_image() - self._update_buttons() + Parameters + ---------- + html: str + The HTML content where image paths need to be modified. - def _update_image(self): - # Method to update the displayed PNG image - if self.image_list is not None: - image_path = self.image_list[self.current_index] - if image_path and image_path.endswith(".png"): - pixmap = QPixmap(image_path) - self.image_widget.setPixmap(pixmap) - self.image_widget.setVisible(True) - else: - self.image_widget.clear() - self.image_widget.setVisible(False) + Returns + ------- + str + The HTML content with updated image paths. + """ + if self.base_dir: + return html.replace('src="', f'src="{self.base_dir}/') + return html def _update_buttons(self): - # Method to update the state of the buttons - if self.info_list is not None: - self.prev_button.setEnabled(self.current_index > 0) - self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) + """ + Update the state of navigation buttons (enabled/disabled). + Controls the "Previous" and "Next" buttons based on the current index. + """ + self.prev_button.setEnabled(self.current_index > 0) + self.next_button.setEnabled(self.current_index < len(self.info_list) - 1) def _show_previous(self): - # Method to show the previous piece of information + """ + Method to show the previous section of information in the dialog. + Decreases the current index by one and updates the displayed content. + """ if self.current_index > 0: self.current_index -= 1 self._update_info() def _show_next(self): - # Method to show the next piece of information + """ + Method to show the next section of information in the dialog. + Increases the current index by one and updates the displayed content. + """ if self.current_index < len(self.info_list) - 1: self.current_index += 1 self._update_info() def _skip(self): - # Method to skip and close the dialog + """ + Method to skip the dialog and close it. + """ self.accept() + + def exec(self): + """ + Overwrite `exec()` to avoid multiple executions. + If dialog is not shown currently, call `exec()` of superclass, else do nothing. + + For more information check documentation of `exec()` in `QtWidgets` module. + """ + if not self.dialog_shown: + super().exec() + self.dialog_shown = True \ No newline at end of file diff --git a/wannadb_ui/resources/info_popups/barchart_tutorial.md b/wannadb_ui/resources/info_popups/barchart_tutorial.md new file mode 100644 index 00000000..6efcc708 --- /dev/null +++ b/wannadb_ui/resources/info_popups/barchart_tutorial.md @@ -0,0 +1,50 @@ +## Hey there! +Before you access the cosine-distance scale, take a moment to read the following tips. +If you are familiar with the metrics used in WANNADB or have gone through this tutorial before, +feel free to exit using the **skip** button. + + + +## Cosine Similarity in 2D Plane: +Imagine that you and a friend are standing in the middle of a field, and both of you +point in different directions. Each direction you point is like a piece of information. +The closer your two arms are to pointing in the same direction, the more similar your +thoughts or ideas are. + +### Same direction: +If you both point in exactly the same direction, it means your ideas (or pieces of information) are exactly alike. +This is like saying: "We’re thinking the same thing!" + +### Opposite direction: +If you point in completely opposite directions, your ideas are as different as they can be. You’re thinking about completely different things. + +### Right angle: +If your arms are at a 90-degree angle, you're pointing in different directions, but not as different as pointing in opposite directions. You’re thinking about different things, but there might still be a tiny bit of connection. + +![Cosine Similarity](cosine_similarity.png) + + + +## Multi Dimensionality of Vectors and Cosine Distance: +Vectors may have more than 2 dimensions, as was the case of you and your friend on the field. The mathematical formula guarantees a value between -1 and 1 for each pair of vectors, for any number of dimensions. + +The cosine similarity is equal to 1 when the vectors point at the same direction, -1 when the vectors point in opposite directions, and 0 when the vectors are perpendicular to each other. + +As cosine similarity expresses how similar two vectors are, a higher value (in the range from -1 to 1) expresses a higher similarity. In **wanna-db** we use the dual concept of cosine distance. Contrary to cosine similarity, a higher value in the cosine distance metric, means a higher degree of dissimilarity. + +_cos-dist(**a**, **b**) = 1 - cos-sim(**a**, **b**)_ + +![Screenshot Grid](screenshot_grid.png) + + + +## Cosine-Driven Choices: Ranking Database Values: +The bar chart shows all nuggets found inside the documents, lined after each other along the x-axis. The y-axis shows the normalized cosine distance. As we mentioned, the lower the cosine distance is, the more certain we are that the corresponding word belongs to what we are looking for: a value in the database. + +### QUESTION: +After you explore the bar chart, ask yourself - do the answers on the left tend to be more plausible? + +### PRO TIP: +Click on each bar to show the exact value, as well as the full information nugget. + +![Screenshot Bar Chart](screenshot_bar_chart.png) diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 9d534ff4..5218b34d 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -1089,71 +1089,9 @@ def plot_bar_chart(self): self.texts = texts self.distances = rounded_certainties - info_list = [ - """ - Hey there!
    - Before you access the cosine-distance scale, take a moment to read the following tips. - If you are familiar with the metrics used in WANNADB or have gone through this tutorial before, - feel free to exit using the skip button. - """, - """Cosine Similarity in 2D Plane:
    - Imagine that you and a friend are standing in the middle of a field, and both of you - point in different directions. Each direction you point is like a piece of information. - The closer your two arms are to pointing in the same direction, the more similar your - thoughts or ideas are.

    - - Same direction: If you both point in exactly the same direction, it means your ideas - (or pieces of information) are exactly alike. This is like saying: - "We’re thinking the same thing!"

    - - Opposite direction: If you point in completely opposite directions, your ideas are as - different as they can be. You’re thinking about completely different things.

    - - Right angle: If your arms are at a 90-degree angle, you're pointing in different directions, - but not as different as pointing in opposite directions. You’re thinking about different things, - but there might still be a tiny bit of connection.

    - - Before skipping over to the next tip, try to reason which vector is the most similar to vector A - in the image below! - """, - """Multi Dimensionality of Vectors and Cosine Distance
    - Vectors may have more than 2 dimensions, as was the case of you and your friend on the field. The - mathematical formula guarantees a value between -1 and 1 for each pair of vectors, for any number - of dimensions.

    - - The cosine similarity is equal to 1 when the vectors point at the same direction, -1 when the vectors - point in opposite directions, and 0 when the vectors are perpendicular to each other.

    - - As cosine similarity expresses how similar two vectors are, a higher value (in the range from -1 to 1) - expresses a higher similarity. In wanna-db we use the dual concept of cosine distance. Contrary to - cosine similarity, a higher value in the cosine distance metric, means a higher degree of dissimilarity. -
    cos-dist(a, b) = 1 - cos-sim(a, b)

    - - Take a look at the image below. The yellow dots are closer to a fixed vector(not shown here), whereas the scattered - red dots are further away. Think about what the varying cosine distances imply for the spatial configuration. - - """, - """Cosine-Driven Choices: Ranking Database Values: - The bar chart shows all nuggets found inside the documents, lined after each other along the x-axis. - The y axis shows the normalized cosine distance. As we mentioned, the lower the cosine distance is, - the more certain we are that the corresponding word belongs to what we are looking for: a value in the database.

    - - - QUESTION: After you explore the bar chart, ask yourself - do the answers on the left tend to be more plausible?

    - PRO TIP: Click on each bar to show the exact value, as well as the full information nugget. - """ - ] - image_list = [ - None, - 'wannadb_ui/resources/info_popups/cosine_similarity.png', # Add the path to an SVG image - 'wannadb_ui/resources/info_popups/screenshot_grid.png', # Regular PNG image - 'wannadb_ui/resources/info_popups/screenshot_bar_chart.png' - ] - global dialog - assert len(info_list) == len(image_list) - dialog.set_info_list(info_list) - dialog.set_image_list(image_list) + dialog.load_markdown_file('wannadb_ui/resources/info_popups/barchart_tutorial.md') # Path to your .md file + # dialog.set_image_list([None, 'image1.png', 'image2.png', 'image3.png']) dialog.exec() def on_pick(self, event): From c32464a51923aefd80a7ba339a0694cdfb6d7753 Mon Sep 17 00:00:00 2001 From: Jannis25 Date: Fri, 3 Jan 2025 16:41:21 +0100 Subject: [PATCH 82/85] Update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ae7fc66a..e9765e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ /models/ /cache/ +/build/ +/executable/ .json .pdf .bson From d596ca2b47512cabdab286d8e5f798ecda28f044 Mon Sep 17 00:00:00 2001 From: Jannis25 Date: Mon, 20 Jan 2025 12:35:19 +0100 Subject: [PATCH 83/85] Update requirements and refactor visualization component in interactive matching --- requirements.txt | 5 +++-- wannadb_ui/interactive_matching.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 79061a97..ff7baffd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -109,6 +109,7 @@ language-data==1.2.0 # via langcodes marisa-trie==1.2.0 # via language-data +markdown==3.7 markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 @@ -331,9 +332,9 @@ yarl==1.12.1 pyqtgraph==0.13.7 -PyOpenGL==3.1.7 +PyOpenGL==3.1.9 -PyOpenGL_accelerate==3.1.7 +PyOpenGL_accelerate==3.1.9 # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 76edea0a..73c64426 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -114,7 +114,7 @@ def __init__(self, interactive_matching_widget, main_window): self.layout.addWidget(self.description) # suggestion visualizer - self.visualize_area = DataInsightsArea() + self.visualize_area = SimpleDataInsightsArea() self.layout.addWidget(self.visualize_area) self.visualize_area.setVisible(False) self.visualizations = True From 697677d86be9a016d1386290066e34cd1e096b93 Mon Sep 17 00:00:00 2001 From: Jannis25 Date: Tue, 21 Jan 2025 18:45:19 +0100 Subject: [PATCH 84/85] Fix errors occured during rebase --- wannadb_ui/data_insights.py | 9 --------- wannadb_ui/interactive_matching.py | 7 ++++--- wannadb_ui/main_window.py | 22 +++++++++++++++++++++- wannadb_ui/visualizations.py | 11 +++-------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/wannadb_ui/data_insights.py b/wannadb_ui/data_insights.py index dca9e247..c7a242ed 100644 --- a/wannadb_ui/data_insights.py +++ b/wannadb_ui/data_insights.py @@ -434,15 +434,6 @@ def __init__(self): self.changes_list3_hbox = QHBoxLayout() self.changes_list3_hbox.setContentsMargins(0, 0, 0, 0) self.changes_list3_hbox.setSpacing(0) - - self.suggestion_visualizer = EmbeddingVisualizerWindow() - self.suggestion_visualizer_button = QPushButton("Show Suggestions In 3D-Grid") - self.suggestion_visualizer_button.setContentsMargins(0, 0, 0, 0) - self.accessible_color_palette = False - self.suggestion_visualizer_button.setFont(BUTTON_FONT) - self.suggestion_visualizer_button.setMaximumWidth(240) - self.suggestion_visualizer_button.clicked.connect(self._show_suggestion_visualizer) - self.changes_best_matches_list = ChangedBestMatchDocumentsList() self.changes_list3_hbox.addWidget(self.changes_best_matches_list) self.changes_list3_hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 73c64426..5e7ac092 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -114,9 +114,10 @@ def __init__(self, interactive_matching_widget, main_window): self.layout.addWidget(self.description) # suggestion visualizer - self.visualize_area = SimpleDataInsightsArea() - self.layout.addWidget(self.visualize_area) - self.visualize_area.setVisible(False) + self.simple_visualize_area = SimpleDataInsightsArea() + self.extended_visualize_area = ExtendedDataInsightsArea() + self.layout.addWidget(self.simple_visualize_area) + self.layout.addWidget(self.extended_visualize_area) self.visualizations = True self.accessible_color_palette = False diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index ad4705ad..aedfcc4d 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -108,7 +108,7 @@ def document_base_to_ui(self, document_base): def statistics_to_ui(self, statistics): logger.debug("Called slot 'statistics_to_ui'.") - self.statistics = statistics + self.statistics = statisticsf @pyqtSlot(SQLiteCacheDB) def cache_db_to_ui(self, cache_db): @@ -310,6 +310,7 @@ def give_feedback_task(self, feedback): self._set_available_visualization_actions() self._enable_color_palette_settings() + def interactive_table_population_task(self): logger.info("Execute task 'interactive_table_population_task'.") @@ -534,9 +535,28 @@ def _enable_visualization_settings(self): self.enable_visualizations_action.setEnabled(not self.visualizations) self.disable_visualizations_action.setEnabled(self.visualizations) + def attach_visualization_level_observer(self, observer): + self.visualizations_level_observers.append(observer) + + + def _set_available_visualization_actions(self): + if self.visualizations_level == AvailableVisualizationsLevel.DISABLED: + self.enable_lvl1_visualizations_action.setEnabled(True) + self.enable_lvl2_visualizations_action.setEnabled(True) + self.disable_visualizations_action.setEnabled(False) + elif self.visualizations_level == AvailableVisualizationsLevel.LEVEL_1: + self.enable_lvl1_visualizations_action.setEnabled(False) + self.enable_lvl2_visualizations_action.setEnabled(True) + self.disable_visualizations_action.setEnabled(True) + elif self.visualizations_level == AvailableVisualizationsLevel.LEVEL_2: + self.enable_lvl1_visualizations_action.setEnabled(True) + self.enable_lvl2_visualizations_action.setEnabled(False) + self.disable_visualizations_action.setEnabled(True) + def _enable_color_palette_settings(self): self.enable_accessible_color_palette_action.setEnabled(not self.accessible_color_palette) self.disable_accessible_color_palette_action.setEnabled(self.accessible_color_palette) + # noinspection PyUnresolvedReferences def __init__(self) -> None: super(MainWindow, self).__init__() diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 5218b34d..30c0f3e1 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -510,8 +510,7 @@ def _determine_update_values(self, previously_selected_nugget) -> ((int, Color), if previously_selected_nugget is None: reset_color = WHITE reset_size = DEFAULT_NUGGET_SIZE - elif (previously_selected_nugget in self._attribute.confirmed_matches or - similar_prev_selected_nugget in self._attribute.confirmed_matches): + elif previously_selected_nugget in self._attribute.confirmed_matches: reset_color = ACC_GREEN if self._accessible_color_palette else GREEN reset_size = DEFAULT_NUGGET_SIZE elif previously_selected_nugget == self._best_guess: @@ -535,12 +534,8 @@ def _determine_nuggets_color(self, nugget: InformationNugget) -> Color: f"Will return purple as color highlighting nuggets with this issue.") return ACC_PURPLE if self._accessible_color_palette else PURPLE - similar_nugget = self._nugget_to_similar_nugget[nugget] if nugget in self._nugget_to_similar_nugget else None - - return (WHITE if nugget[CachedDistanceSignal] < self._attribute[CurrentThresholdSignal] or - (similar_nugget is not None and similar_nugget[CachedDistanceSignal] < self._attribute[ - CurrentThresholdSignal]) - else ACC_RED if self.accessible_color_palette else RED) + return WHITE if nugget[CachedDistanceSignal] < self._attribute[ + CurrentThresholdSignal] else ACC_RED if self._accessible_color_palette else RED def _add_grids(self): # Adds the UI items realizing the grid From 985ca814b97a8b83717106e705d564a156829232 Mon Sep 17 00:00:00 2001 From: Jannis25 Date: Mon, 18 Aug 2025 14:48:59 +0200 Subject: [PATCH 85/85] Refactor code for clarity and fix minor issues in dimension reduction, interactive matching, main window, and visualizations (Copilot PR Review) --- wannadb/preprocessing/dimension_reduction.py | 2 +- wannadb_ui/interactive_matching.py | 2 +- wannadb_ui/main_window.py | 6 +- wannadb_ui/visualizations.py | 185 ++----------------- 4 files changed, 17 insertions(+), 178 deletions(-) diff --git a/wannadb/preprocessing/dimension_reduction.py b/wannadb/preprocessing/dimension_reduction.py index 4ca20709..b449a439 100644 --- a/wannadb/preprocessing/dimension_reduction.py +++ b/wannadb/preprocessing/dimension_reduction.py @@ -47,7 +47,7 @@ def __call__( status_callback: BaseStatusCallback, statistics: Statistics ) -> None: - #Assume that all embeddings have same number of features + # Assume that all embeddings have same number of features attribute_embeddings = [attribute[LabelEmbeddingSignal] for attribute in document_base.attributes] nugget_embeddings = [nugget[TextEmbeddingSignal] for nugget in document_base.nuggets] all_embeddings = attribute_embeddings + nugget_embeddings diff --git a/wannadb_ui/interactive_matching.py b/wannadb_ui/interactive_matching.py index 5e7ac092..df19641e 100644 --- a/wannadb_ui/interactive_matching.py +++ b/wannadb_ui/interactive_matching.py @@ -754,7 +754,7 @@ def __init__(self, suggestion_list_widget, visualizations_level): self.certainty_label = QLabel() self.certainty_label.setFont(CODE_FONT) - self.layout.addWidget(self.certainty_label), 0, 1 + self.layout.addWidget(self.certainty_label, 0, 1) if not self.visualizations: self.certainty_label.hide() diff --git a/wannadb_ui/main_window.py b/wannadb_ui/main_window.py index aedfcc4d..cf9f909d 100644 --- a/wannadb_ui/main_window.py +++ b/wannadb_ui/main_window.py @@ -108,7 +108,7 @@ def document_base_to_ui(self, document_base): def statistics_to_ui(self, statistics): logger.debug("Called slot 'statistics_to_ui'.") - self.statistics = statisticsf + self.statistics = statistics @pyqtSlot(SQLiteCacheDB) def cache_db_to_ui(self, cache_db): @@ -738,10 +738,6 @@ def __init__(self) -> None: self.open_general_info.setStatusTip("Open popup providing some general information about the application.") self.open_general_info.triggered.connect(self.open_general_info_task) - self.open_usage_info = QAction("&Open usage info", self) - self.open_usage_info.setStatusTip("Open usage popup providing some usage information.") - self.open_usage_info.triggered.connect(self.open_usage_info_task) - # set up the menu bar self.menubar = self.menuBar() diff --git a/wannadb_ui/visualizations.py b/wannadb_ui/visualizations.py index 30c0f3e1..739b3052 100644 --- a/wannadb_ui/visualizations.py +++ b/wannadb_ui/visualizations.py @@ -57,9 +57,19 @@ DEFAULT_NUGGET_SIZE = 10 HIGHLIGHT_SIZE = 17 -app = QApplication([]) -screen = app.primaryScreen() -screen_geometry = screen.geometry() +def initialize_app(): + """ + Initializes the PyQt application and sets up the main window. + This function is typically called at the start of the application. + """ + app = QApplication.getInstance() + if app is None: + app = QApplication([]) + screen = app.primaryScreen() + screen_geometry = screen.geometry() + return app, screen_geometry + +app, screen_geometry = initialize_app() WINDOW_WIDTH = int(screen_geometry.width() * 0.7) WINDOW_HEIGHT = int(screen_geometry.height() * 0.7) @@ -1161,173 +1171,7 @@ def disable_accessible_color_palette(self): def update_data(self, nuggets): self.reset() - self.data = [(create_sanitized_text(nugget), - np.round(nugget[CachedDistanceSignal], 3)) - for nugget in nuggets] - - def reset(self): - self.data = [] - self.texts = None - self.distances = None - self.y = None - self.scatter = None - if self.window is not None: - self.window.close() - self.scatter_plot_canvas = None - self.scatter_plot_toolbar = None - self.window = None - self.annotation = None - - @track_button_click("show scatter plot") - def show_scatter_plot(self): - if not self.data: - return - - # Clear data to prevent duplication - self.data = list(set(self.data)) - - # Close existing scatter plot - if self.window is not None: - self.window.close() - - fig = Figure() - ax = fig.add_subplot(111) - texts, distances = zip(*self.data) - - # Round the distances to a fixed number of decimal places - rounded_distances = np.round(distances, 3) - - # Ensure consistent x-values for the same rounded distance - distance_map = {} - for original, rounded in zip(distances, rounded_distances): - if rounded not in distance_map: - distance_map[rounded] = original - - consistent_distances = [distance_map[rd] for rd in rounded_distances] - - # Generate jittered y-values for points with the same x-value - unique_distances = {} - for i, distance in enumerate(consistent_distances): - if distance not in unique_distances: - unique_distances[distance] = [] - unique_distances[distance].append(i) - - y = np.zeros(len(distances)) - for distance, indices in unique_distances.items(): - jitter = np.linspace(-0.4, 0.4, len(indices)) - for j, index in enumerate(indices): - y[index] = jitter[j] - - # Generating a list of colors for each point - num_points = len(distances) - colormap = plt.cm.jet - norm = plt.Normalize(min(rounded_distances), max(rounded_distances)) - colors = colormap(norm(rounded_distances)) - - # Plot the points - scatter = ax.scatter(rounded_distances, y, c=colors, alpha=0.75, picker=True) # Enable picking - - ax.set_xlabel("Cosine Distance") - ax.set_xlim(min(rounded_distances) - 0.05, - max(rounded_distances) + 0.05) # Adjust x-axis limits for better visibility - ax.set_yticks([]) # Remove y-axis labels to avoid confusion - fig.subplots_adjust(left=0.020, right=0.980, top=0.940, bottom=0.075) - # fig.tight_layout() - - # Create canvas - self.scatter_plot_canvas = FigureCanvas(fig) - - # Create a new window for the plot - self.window = QMainWindow() - self.window.closeEvent = self.closeWindowEvent - self.window.showEvent = self.showWindowEvent - self.window.setWindowTitle("Scatter Plot") - - self.window.setGeometry(100, 100, WINDOW_WIDTH, WINDOW_HEIGHT) - - # Set the central widget of the window to the canvas - self.window.setCentralWidget(self.scatter_plot_canvas) - - # Add NavigationToolbar to the window - self.scatter_plot_toolbar = NavigationToolbar(self.scatter_plot_canvas, self.window) - self.window.addToolBar(self.scatter_plot_toolbar) - - # Show the window - self.window.show() - self.scatter_plot_canvas.draw() - - # Create an annotation box - self.annotation = ax.annotate( - "", xy=(0, 0), xytext=(20, 20), - textcoords="offset points", bbox=dict(boxstyle="round", fc="w"), - arrowprops=dict(arrowstyle="->") - ) - self.annotation.set_visible(False) - - # Connect the pick event - self.scatter_plot_canvas.mpl_connect("pick_event", self.on_pick) - - # Store the data for use in the event handler - self.texts = texts - self.distances = rounded_distances - self.y = y - self.scatter = scatter - - def on_pick(self, event): - if event.artist != self.scatter: - return - # Get index of the picked point - ind = event.ind[0] - - # Update annotation text and position - self.annotation.xy = (self.distances[ind], self.y[ind]) - text = f"Text: {self.texts[ind]}\nValue: {self.distances[ind]:.3f}" - self.annotation.set_text(text) - self.annotation.set_visible(True) - self.scatter_plot_canvas.draw_idle() - - def reset(self): - self.data = [] - self.bar = None - - def showWindowEvent(self, event): - super().showEvent(event) - Tracker().start_timer(str(self.__class__)) - - def closeWindowEvent(self, event): - event.accept() - Tracker().stop_timer(str(self.__class__)) - - -class ScatterPlotVisualizerWidget(QWidget): - def __init__(self, parent=None): - super(ScatterPlotVisualizerWidget, self).__init__(parent) - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.button = QPushButton("Show Scatter Plot with Cosine Distances") - self.layout.addWidget(self.button) - self.data = [] # Store data as a list of tuples - self.button.clicked.connect(self.show_scatter_plot) - self.scatter_plot_canvas = None - self.scatter_plot_toolbar = None - self.window = None - self.annotation = None - self.texts = None - self.distances = None - self.y = None - self.scatter = None - self.accessible_color_palette = False - - def enable_accessible_color_palette(self): - self.accessible_color_palette = True - - def disable_accessible_color_palette(self): - self.accessible_color_palette = False - - def update_data(self, nuggets): - self.reset() - - self.data = [(create_sanitized_text(nugget), + self.data = [(_create_sanitized_text(nugget), np.round(nugget[CachedDistanceSignal], 3)) for nugget in nuggets] @@ -1464,4 +1308,3 @@ def closeWindowEvent(self, event): event.accept() Tracker().stop_timer(str(self.__class__)) -