Skip to content

Commit

Permalink
Merge pull request #6752 from VesnaT/scatter_ellipse
Browse files Browse the repository at this point in the history
Scatter Plot: Add ellipse/s
  • Loading branch information
lanzagar authored Apr 12, 2024
2 parents 58bee03 + cef04c9 commit 100d989
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 15 deletions.
92 changes: 79 additions & 13 deletions Orange/widgets/visualize/owscatterplot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import math
from typing import List, Callable
from xml.sax.saxutils import escape

import numpy as np
import scipy.stats as ss
from scipy.stats import linregress
from sklearn.neighbors import NearestNeighbors
from sklearn.metrics import r2_score
Expand Down Expand Up @@ -104,6 +107,8 @@ def update_lines(**settings):
self.reg_line_settings.update(**settings)
Updater.update_inf_lines(self.reg_line_items,
**self.reg_line_settings)
Updater.update_lines(self.ellipse_items,
**self.reg_line_settings)
self.master.update_reg_line_label_colors()

def update_line_label(**settings):
Expand All @@ -129,20 +134,27 @@ def reg_line_label_items(self):
return [line.label for line in self.master.reg_line_items
if hasattr(line, "label")]

@property
def ellipse_items(self):
return self.master.ellipse_items


class OWScatterPlotGraph(OWScatterPlotBase):
show_reg_line = Setting(False)
orthonormal_regression = Setting(False)
show_ellipse = Setting(False)
jitter_continuous = Setting(False)

def __init__(self, scatter_widget, parent):
super().__init__(scatter_widget, parent)
self.parameter_setter = ParameterSetter(self)
self.reg_line_items = []
self.ellipse_items: List[pg.PlotCurveItem] = []

def clear(self):
super().clear()
self.reg_line_items.clear()
self.ellipse_items.clear()

def update_coordinates(self):
super().update_coordinates()
Expand All @@ -153,6 +165,7 @@ def update_coordinates(self):
def update_colors(self):
super().update_colors()
self.update_regression_line()
self.update_ellipse()

def jitter_coordinates(self, x, y):
def get_span(attr):
Expand Down Expand Up @@ -255,17 +268,28 @@ def update_density(self):
self.update_reg_line_label_colors()

def update_regression_line(self):
for line in self.reg_line_items:
self.plot_widget.removeItem(line)
self.reg_line_items.clear()
if not (self.show_reg_line
and self.master.can_draw_regresssion_line()):
self._update_curve(self.reg_line_items,
self.show_reg_line,
self._add_line)
self.update_reg_line_label_colors()

def update_ellipse(self):
self._update_curve(self.ellipse_items,
self.show_ellipse,
self._add_ellipse)

def _update_curve(self, items: List, show: bool, add: Callable):
for item in items:
self.plot_widget.removeItem(item)
items.clear()
if not (show and self.master.can_draw_regression_line()):
return
x, y = self.master.get_coordinates_data()
if x is None:
if x is None or len(x) < 2:
return
self._add_line(x, y, QColor("#505050"))
if self.master.is_continuous_color() or self.palette is None:
add(x, y, QColor("#505050"))
if self.master.is_continuous_color() or self.palette is None \
or len(self.palette) == 0:
return
c_data = self.master.get_color_data()
if c_data is None:
Expand All @@ -274,8 +298,40 @@ def update_regression_line(self):
for val in range(c_data.max() + 1):
mask = c_data == val
if mask.sum() > 1:
self._add_line(x[mask], y[mask], self.palette[val].darker(135))
self.update_reg_line_label_colors()
add(x[mask], y[mask], self.palette[val].darker(135))

def _add_ellipse(self, x: np.ndarray, y: np.ndarray, color: QColor) -> np.ndarray:
# https://github.com/ChristianGoueguel/HotellingEllipse/blob/master/R/ellipseCoord.R
points = np.vstack([x, y]).T
mu = np.mean(points, axis=0)
cov = np.cov(*(points - mu).T)
vals, vects = np.linalg.eig(cov)
angle = math.atan2(vects[1, 0], vects[0, 0])
matrix = np.array([[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)]])

n = len(x)
f = ss.f.ppf(0.95, 2, n - 2)
f = f * 2 * (n - 1) / (n - 2)
m = [np.pi * i / 100 for i in range(201)]
cx = np.cos(m) * np.sqrt(vals[0] * f)
cy = np.sin(m) * np.sqrt(vals[1] * f)

pts = np.vstack([cx, cy])
pts = matrix.dot(pts)
cx = pts[0] + mu[0]
cy = pts[1] + mu[1]

width = self.parameter_setter.reg_line_settings[Updater.WIDTH_LABEL]
alpha = self.parameter_setter.reg_line_settings[Updater.ALPHA_LABEL]
style = self.parameter_setter.reg_line_settings[Updater.STYLE_LABEL]
style = Updater.LINE_STYLES[style]
color.setAlpha(alpha)

pen = pg.mkPen(color=color, width=width, style=style)
ellipse = pg.PlotCurveItem(cx, cy, pen=pen)
self.plot_widget.addItem(ellipse)
self.ellipse_items.append(ellipse)


class OWScatterPlot(OWDataProjectionWidget, VizRankMixin(ScatterPlotVizRank)):
Expand Down Expand Up @@ -353,6 +409,12 @@ def _add_controls(self):
"If checked, fit line to group (minimize distance from points);\n"
"otherwise fit y as a function of x (minimize vertical distances)",
disabledBy=self.cb_reg_line)
gui.checkBox(
self._plot_box, self,
value="graph.show_ellipse",
label="Show confidence ellipse",
tooltip="Hotelling's T² confidence ellipse (α=95%)",
callback=self.graph.update_ellipse)

def _add_controls_axis(self):
common_options = dict(
Expand Down Expand Up @@ -492,7 +554,7 @@ def _point_tooltip(self, point_id, skip_attrs=()):
text = "<b>{}</b><br/><br/>{}".format(text, others)
return text

def can_draw_regresssion_line(self):
def can_draw_regression_line(self):
return self.data is not None and \
self.data.domain is not None and \
self.attr_x is not None and self.attr_y is not None and \
Expand Down Expand Up @@ -552,7 +614,9 @@ def handleNewSignals(self):
if self._domain_invalidated:
self.graph.update_axes()
self._domain_invalidated = False
self.cb_reg_line.setEnabled(self.can_draw_regresssion_line())
can_plot = self.can_draw_regression_line()
self.cb_reg_line.setEnabled(can_plot)
self.graph.controls.show_ellipse.setEnabled(can_plot)

@Inputs.features
def set_shown_attributes(self, attributes):
Expand All @@ -578,7 +642,9 @@ def set_attr_from_combo(self):
self.vizrankAutoSelect.emit([self.attr_x, self.attr_y])

def attr_changed(self):
self.cb_reg_line.setEnabled(self.can_draw_regresssion_line())
can_plot = self.can_draw_regression_line()
self.cb_reg_line.setEnabled(can_plot)
self.graph.controls.show_ellipse.setEnabled(can_plot)
self.setup_plot()
self.commit.deferred()

Expand Down
58 changes: 56 additions & 2 deletions Orange/widgets/visualize/tests/test_owscatterplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ def test_regression_line_pair(self):
self.assertFalse(self.widget.cb_reg_line.isEnabled())
self.assertListEqual([], self.widget.graph.reg_line_items)

def test_ellipse_pair(self):
self.send_signal(self.widget.Inputs.data, self.data)
self.assertTrue(self.widget.graph.controls.show_ellipse.isEnabled())
self.assertListEqual([], self.widget.graph.ellipse_items)
self.widget.graph.controls.show_ellipse.setChecked(True)
self.assertEqual(4, len(self.widget.graph.ellipse_items))
self.widget.cb_attr_y.activated.emit(4)
self.widget.cb_attr_y.setCurrentIndex(4)
self.assertFalse(self.widget.graph.controls.show_ellipse.isEnabled())
self.assertListEqual([], self.widget.graph.ellipse_items)

def test_points_combo_boxes(self):
"""Check Point box combo models and values"""
self.send_signal(self.widget.Inputs.data, self.data)
Expand Down Expand Up @@ -867,14 +878,27 @@ def test_regression_lines_appear(self):
self.send_signal(self.widget.Inputs.data, data)
self.assertEqual(len(self.widget.graph.reg_line_items), 0)

def test_ellipse_appear(self):
self.widget.graph.controls.show_ellipse.setChecked(True)
self.assertEqual(len(self.widget.graph.ellipse_items), 0)
self.send_signal(self.widget.Inputs.data, self.data)
self.assertEqual(len(self.widget.graph.ellipse_items), 4)
simulate.combobox_activate_index(self.widget.controls.attr_color, 0)
self.assertEqual(len(self.widget.graph.ellipse_items), 1)
data = self.data.copy()
with data.unlocked():
data[:, 0] = np.nan
self.send_signal(self.widget.Inputs.data, data)
self.assertEqual(len(self.widget.graph.ellipse_items), 0)

def test_regression_line_coeffs(self):
widget = self.widget
graph = widget.graph
xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2],
[0, 1], [1, 3], [2, 5]], dtype=float)
colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float)
widget.get_coordinates_data = lambda: xy.T
widget.can_draw_regresssion_line = lambda: True
widget.can_draw_regression_line = lambda: True
widget.get_color_data = lambda: colors
widget.is_continuous_color = lambda: False
graph.palette = DefaultRGBColors
Expand Down Expand Up @@ -909,6 +933,33 @@ def test_regression_line_coeffs(self):
self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1)))
self.assertEqual(line2.pen.color().hue(), graph.palette[1].hue())

def test_ellipse_coeffs(self):
widget = self.widget
graph = widget.graph
xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2],
[0, 1], [1, 3], [2, 5]], dtype=float)
colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float)
widget.get_coordinates_data = lambda: xy.T
widget.can_draw_regression_line = lambda: True
widget.get_color_data = lambda: colors
widget.is_continuous_color = lambda: False
graph.palette = DefaultRGBColors
graph.controls.show_ellipse.setChecked(True)

graph.update_ellipse()

item = graph.ellipse_items[1]
self.assertEqual(item.pos().x(), 0)
self.assertEqual(item.pos().y(), 0)
self.assertEqual(item.opts["pen"].color().hue(),
graph.palette[0].hue())

item = graph.ellipse_items[2]
self.assertEqual(item.pos().x(), 0)
self.assertEqual(item.pos().y(), 0)
self.assertEqual(item.opts["pen"].color().hue(),
graph.palette[1].hue())

def test_orthonormal_line(self):
color = QColor(1, 2, 3)
width = 42
Expand Down Expand Up @@ -1024,7 +1075,7 @@ def test_update_regression_line_calls_add_line(self):
[0, 1], [1, 3], [2, 5]], dtype=float).T
colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float)
widget.get_coordinates_data = lambda: (x, y)
widget.can_draw_regresssion_line = lambda: True
widget.can_draw_regression_line = lambda: True
widget.get_color_data = lambda: colors
widget.is_continuous_color = lambda: False
graph.palette = DefaultRGBColors
Expand Down Expand Up @@ -1189,6 +1240,7 @@ def test_visual_settings(self, timeout=DEFAULT_TIMEOUT):

self.widget.graph.controls.show_reg_line.setChecked(True)
self.assertGreater(len(graph.parameter_setter.reg_line_label_items), 0)
self.widget.graph.controls.show_ellipse.setChecked(True)

key, value = ('Fonts', 'Line label', 'Font size'), 16
self.widget.set_visual_settings(key, value)
Expand All @@ -1202,6 +1254,8 @@ def test_visual_settings(self, timeout=DEFAULT_TIMEOUT):
self.widget.set_visual_settings(key, value)
for item in graph.reg_line_items:
self.assertEqual(item.pen.width(), 10)
for item in graph.ellipse_items:
self.assertEqual(item.opts["pen"].width(), 10)


if __name__ == "__main__":
Expand Down

0 comments on commit 100d989

Please sign in to comment.