Skip to content

Commit 100d989

Browse files
authored
Merge pull request #6752 from VesnaT/scatter_ellipse
Scatter Plot: Add ellipse/s
2 parents 58bee03 + cef04c9 commit 100d989

File tree

2 files changed

+135
-15
lines changed

2 files changed

+135
-15
lines changed

Orange/widgets/visualize/owscatterplot.py

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import math
2+
from typing import List, Callable
13
from xml.sax.saxutils import escape
24

35
import numpy as np
6+
import scipy.stats as ss
47
from scipy.stats import linregress
58
from sklearn.neighbors import NearestNeighbors
69
from sklearn.metrics import r2_score
@@ -104,6 +107,8 @@ def update_lines(**settings):
104107
self.reg_line_settings.update(**settings)
105108
Updater.update_inf_lines(self.reg_line_items,
106109
**self.reg_line_settings)
110+
Updater.update_lines(self.ellipse_items,
111+
**self.reg_line_settings)
107112
self.master.update_reg_line_label_colors()
108113

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

137+
@property
138+
def ellipse_items(self):
139+
return self.master.ellipse_items
140+
132141

133142
class OWScatterPlotGraph(OWScatterPlotBase):
134143
show_reg_line = Setting(False)
135144
orthonormal_regression = Setting(False)
145+
show_ellipse = Setting(False)
136146
jitter_continuous = Setting(False)
137147

138148
def __init__(self, scatter_widget, parent):
139149
super().__init__(scatter_widget, parent)
140150
self.parameter_setter = ParameterSetter(self)
141151
self.reg_line_items = []
152+
self.ellipse_items: List[pg.PlotCurveItem] = []
142153

143154
def clear(self):
144155
super().clear()
145156
self.reg_line_items.clear()
157+
self.ellipse_items.clear()
146158

147159
def update_coordinates(self):
148160
super().update_coordinates()
@@ -153,6 +165,7 @@ def update_coordinates(self):
153165
def update_colors(self):
154166
super().update_colors()
155167
self.update_regression_line()
168+
self.update_ellipse()
156169

157170
def jitter_coordinates(self, x, y):
158171
def get_span(attr):
@@ -255,17 +268,28 @@ def update_density(self):
255268
self.update_reg_line_label_colors()
256269

257270
def update_regression_line(self):
258-
for line in self.reg_line_items:
259-
self.plot_widget.removeItem(line)
260-
self.reg_line_items.clear()
261-
if not (self.show_reg_line
262-
and self.master.can_draw_regresssion_line()):
271+
self._update_curve(self.reg_line_items,
272+
self.show_reg_line,
273+
self._add_line)
274+
self.update_reg_line_label_colors()
275+
276+
def update_ellipse(self):
277+
self._update_curve(self.ellipse_items,
278+
self.show_ellipse,
279+
self._add_ellipse)
280+
281+
def _update_curve(self, items: List, show: bool, add: Callable):
282+
for item in items:
283+
self.plot_widget.removeItem(item)
284+
items.clear()
285+
if not (show and self.master.can_draw_regression_line()):
263286
return
264287
x, y = self.master.get_coordinates_data()
265-
if x is None:
288+
if x is None or len(x) < 2:
266289
return
267-
self._add_line(x, y, QColor("#505050"))
268-
if self.master.is_continuous_color() or self.palette is None:
290+
add(x, y, QColor("#505050"))
291+
if self.master.is_continuous_color() or self.palette is None \
292+
or len(self.palette) == 0:
269293
return
270294
c_data = self.master.get_color_data()
271295
if c_data is None:
@@ -274,8 +298,40 @@ def update_regression_line(self):
274298
for val in range(c_data.max() + 1):
275299
mask = c_data == val
276300
if mask.sum() > 1:
277-
self._add_line(x[mask], y[mask], self.palette[val].darker(135))
278-
self.update_reg_line_label_colors()
301+
add(x[mask], y[mask], self.palette[val].darker(135))
302+
303+
def _add_ellipse(self, x: np.ndarray, y: np.ndarray, color: QColor) -> np.ndarray:
304+
# https://github.com/ChristianGoueguel/HotellingEllipse/blob/master/R/ellipseCoord.R
305+
points = np.vstack([x, y]).T
306+
mu = np.mean(points, axis=0)
307+
cov = np.cov(*(points - mu).T)
308+
vals, vects = np.linalg.eig(cov)
309+
angle = math.atan2(vects[1, 0], vects[0, 0])
310+
matrix = np.array([[np.cos(angle), -np.sin(angle)],
311+
[np.sin(angle), np.cos(angle)]])
312+
313+
n = len(x)
314+
f = ss.f.ppf(0.95, 2, n - 2)
315+
f = f * 2 * (n - 1) / (n - 2)
316+
m = [np.pi * i / 100 for i in range(201)]
317+
cx = np.cos(m) * np.sqrt(vals[0] * f)
318+
cy = np.sin(m) * np.sqrt(vals[1] * f)
319+
320+
pts = np.vstack([cx, cy])
321+
pts = matrix.dot(pts)
322+
cx = pts[0] + mu[0]
323+
cy = pts[1] + mu[1]
324+
325+
width = self.parameter_setter.reg_line_settings[Updater.WIDTH_LABEL]
326+
alpha = self.parameter_setter.reg_line_settings[Updater.ALPHA_LABEL]
327+
style = self.parameter_setter.reg_line_settings[Updater.STYLE_LABEL]
328+
style = Updater.LINE_STYLES[style]
329+
color.setAlpha(alpha)
330+
331+
pen = pg.mkPen(color=color, width=width, style=style)
332+
ellipse = pg.PlotCurveItem(cx, cy, pen=pen)
333+
self.plot_widget.addItem(ellipse)
334+
self.ellipse_items.append(ellipse)
279335

280336

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

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

495-
def can_draw_regresssion_line(self):
557+
def can_draw_regression_line(self):
496558
return self.data is not None and \
497559
self.data.domain is not None and \
498560
self.attr_x is not None and self.attr_y is not None and \
@@ -552,7 +614,9 @@ def handleNewSignals(self):
552614
if self._domain_invalidated:
553615
self.graph.update_axes()
554616
self._domain_invalidated = False
555-
self.cb_reg_line.setEnabled(self.can_draw_regresssion_line())
617+
can_plot = self.can_draw_regression_line()
618+
self.cb_reg_line.setEnabled(can_plot)
619+
self.graph.controls.show_ellipse.setEnabled(can_plot)
556620

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

580644
def attr_changed(self):
581-
self.cb_reg_line.setEnabled(self.can_draw_regresssion_line())
645+
can_plot = self.can_draw_regression_line()
646+
self.cb_reg_line.setEnabled(can_plot)
647+
self.graph.controls.show_ellipse.setEnabled(can_plot)
582648
self.setup_plot()
583649
self.commit.deferred()
584650

Orange/widgets/visualize/tests/test_owscatterplot.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ def test_regression_line_pair(self):
152152
self.assertFalse(self.widget.cb_reg_line.isEnabled())
153153
self.assertListEqual([], self.widget.graph.reg_line_items)
154154

155+
def test_ellipse_pair(self):
156+
self.send_signal(self.widget.Inputs.data, self.data)
157+
self.assertTrue(self.widget.graph.controls.show_ellipse.isEnabled())
158+
self.assertListEqual([], self.widget.graph.ellipse_items)
159+
self.widget.graph.controls.show_ellipse.setChecked(True)
160+
self.assertEqual(4, len(self.widget.graph.ellipse_items))
161+
self.widget.cb_attr_y.activated.emit(4)
162+
self.widget.cb_attr_y.setCurrentIndex(4)
163+
self.assertFalse(self.widget.graph.controls.show_ellipse.isEnabled())
164+
self.assertListEqual([], self.widget.graph.ellipse_items)
165+
155166
def test_points_combo_boxes(self):
156167
"""Check Point box combo models and values"""
157168
self.send_signal(self.widget.Inputs.data, self.data)
@@ -867,14 +878,27 @@ def test_regression_lines_appear(self):
867878
self.send_signal(self.widget.Inputs.data, data)
868879
self.assertEqual(len(self.widget.graph.reg_line_items), 0)
869880

881+
def test_ellipse_appear(self):
882+
self.widget.graph.controls.show_ellipse.setChecked(True)
883+
self.assertEqual(len(self.widget.graph.ellipse_items), 0)
884+
self.send_signal(self.widget.Inputs.data, self.data)
885+
self.assertEqual(len(self.widget.graph.ellipse_items), 4)
886+
simulate.combobox_activate_index(self.widget.controls.attr_color, 0)
887+
self.assertEqual(len(self.widget.graph.ellipse_items), 1)
888+
data = self.data.copy()
889+
with data.unlocked():
890+
data[:, 0] = np.nan
891+
self.send_signal(self.widget.Inputs.data, data)
892+
self.assertEqual(len(self.widget.graph.ellipse_items), 0)
893+
870894
def test_regression_line_coeffs(self):
871895
widget = self.widget
872896
graph = widget.graph
873897
xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2],
874898
[0, 1], [1, 3], [2, 5]], dtype=float)
875899
colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float)
876900
widget.get_coordinates_data = lambda: xy.T
877-
widget.can_draw_regresssion_line = lambda: True
901+
widget.can_draw_regression_line = lambda: True
878902
widget.get_color_data = lambda: colors
879903
widget.is_continuous_color = lambda: False
880904
graph.palette = DefaultRGBColors
@@ -909,6 +933,33 @@ def test_regression_line_coeffs(self):
909933
self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1)))
910934
self.assertEqual(line2.pen.color().hue(), graph.palette[1].hue())
911935

936+
def test_ellipse_coeffs(self):
937+
widget = self.widget
938+
graph = widget.graph
939+
xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2],
940+
[0, 1], [1, 3], [2, 5]], dtype=float)
941+
colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float)
942+
widget.get_coordinates_data = lambda: xy.T
943+
widget.can_draw_regression_line = lambda: True
944+
widget.get_color_data = lambda: colors
945+
widget.is_continuous_color = lambda: False
946+
graph.palette = DefaultRGBColors
947+
graph.controls.show_ellipse.setChecked(True)
948+
949+
graph.update_ellipse()
950+
951+
item = graph.ellipse_items[1]
952+
self.assertEqual(item.pos().x(), 0)
953+
self.assertEqual(item.pos().y(), 0)
954+
self.assertEqual(item.opts["pen"].color().hue(),
955+
graph.palette[0].hue())
956+
957+
item = graph.ellipse_items[2]
958+
self.assertEqual(item.pos().x(), 0)
959+
self.assertEqual(item.pos().y(), 0)
960+
self.assertEqual(item.opts["pen"].color().hue(),
961+
graph.palette[1].hue())
962+
912963
def test_orthonormal_line(self):
913964
color = QColor(1, 2, 3)
914965
width = 42
@@ -1024,7 +1075,7 @@ def test_update_regression_line_calls_add_line(self):
10241075
[0, 1], [1, 3], [2, 5]], dtype=float).T
10251076
colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float)
10261077
widget.get_coordinates_data = lambda: (x, y)
1027-
widget.can_draw_regresssion_line = lambda: True
1078+
widget.can_draw_regression_line = lambda: True
10281079
widget.get_color_data = lambda: colors
10291080
widget.is_continuous_color = lambda: False
10301081
graph.palette = DefaultRGBColors
@@ -1189,6 +1240,7 @@ def test_visual_settings(self, timeout=DEFAULT_TIMEOUT):
11891240

11901241
self.widget.graph.controls.show_reg_line.setChecked(True)
11911242
self.assertGreater(len(graph.parameter_setter.reg_line_label_items), 0)
1243+
self.widget.graph.controls.show_ellipse.setChecked(True)
11921244

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

12061260

12071261
if __name__ == "__main__":

0 commit comments

Comments
 (0)