-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscatter.py
More file actions
447 lines (406 loc) · 19.1 KB
/
scatter.py
File metadata and controls
447 lines (406 loc) · 19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
from bokeh.plotting import figure
from bokeh.models import HoverTool, TapTool, Legend, LegendItem, Span, Range1d
from functools import partial # used for calling on_click_callback
import logging
import math
import numpy as np
import pandas as pd
import panel as pn
import param
from algorithm_pairs_selector import AlgorithmPairsSelector
from experiment_data import NumericAttribute, Algorithm
from problem_table import ProblemTable
from report import Report
logger = logging.getLogger("visualizer.scatter")
MARKERS = ["x", "circle", "square", "triangle", "asterisk",
"diamond", "cross", "star", "inverted_triangle", "plus",
"hex", "y", "circle_cross", "square_cross", "diamond_cross",
"circle_x", "square_x", "square_pin", "triangle_pin"]
COLORS = ["black", "red", "blue", "teal", "orange",
"purple", "olive", "lime", "cyan"]
class ScatterReport(Report):
# widget parameters
x_attribute = param.Parameter(label="X Axis Attribute", default="")
y_attribute = param.Parameter(label="Y Axis Attribute", default="")
algorithm_pairs_selector = param.Parameter()
x_scale = param.Selector(label="X Axis Scale", objects=["log", "linear"], default="log")
y_scale = param.Selector(label="Y Axis Scale", objects=["log", "linear"], default="log")
relative = param.Boolean(label="Y Axis relative to X Axis", default=False)
group_by = param.Selector(label="Group By", objects=["name", "domain"], default="name")
replace_zero = param.Number(label="Replace 0 with", default=0, doc="Replace all 0 values with the given values (useful for log plots).")
marker_size = param.Integer(label="Marker Size", default = 7, bounds = (2,50))
marker_fill_alpha = param.Number(label="Marker Fill Alpha", default = 0.0, bounds=(0.0,1.0))
markers = param.List(label="Markers", default=MARKERS, doc="A list of marker shapes to use in the plot. When more subplots than markers exist, the list is repeated. All matplotlib shapes are compatible")
colors = param.List(label="Colors", default = COLORS, doc="A list of marker colors to use in the plot. When more subplots than colors exist, the list is repeated.")
legend_width = param.Integer(label="Legend Width", default = 1500, bounds = (200,5000))
# internal parameters
df = param.Parameter(precedence=-1)
# config string parameters
aps_config = param.List(default=[], precedence=-1)
def __init__(self, experiment_data, **params):
super().__init__(experiment_data, **params)
self.report_information ="""
<p>Compares two attribute/algorithm combinations on all problems.</p>
<p>Clicking on a datapoint will highlight this point and open a
popup with a Problem Table for that particular problem.
Several popups can be open at the same time.</p>"""
self.algorithm_pairs_selector = AlgorithmPairsSelector(self.experiment_data)
self.data_view = pn.Column(sizing_mode="stretch_both")
self.param_view.extend([
pn.Row(
pn.pane.HTML(
"<label>X Attribute</label>",
margin=(5, 0, -5, 0),
sizing_mode="stretch_width"),
pn.pane.HTML(
"<label>Y Attribute</label>",
margin=(5, 0, -5, 0),
sizing_mode="stretch_width")
),
pn.Row(
pn.widgets.AutocompleteInput.from_param(
self.param.x_attribute,
name="",
options=self.experiment_data.param.numeric_attributes,
case_sensitive=False,
search_strategy='includes',
restrict=False,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width",
),
pn.widgets.AutocompleteInput.from_param(
self.param.y_attribute,
name="",
options=self.experiment_data.param.numeric_attributes,
case_sensitive=False,
search_strategy='includes',
restrict=False,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width",
),
sizing_mode="stretch_width"
),
self.algorithm_pairs_selector,
pn.Row(
pn.pane.HTML(
"<label>X Scale</label>",
margin=(5, 0, -5, 0),
sizing_mode="stretch_width"),
pn.pane.HTML(
"<label>Y Scale</label>",
margin=(5, 0, -5, 0),
sizing_mode="stretch_width")
),
pn.Row(
pn.widgets.RadioButtonGroup.from_param(
self.param.x_scale,
name="",
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.RadioButtonGroup.from_param(
self.param.y_scale,
name="",
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
sizing_mode="stretch_width"
),
pn.Row(
pn.widgets.Checkbox.from_param(
self.param.relative,
margin=(5, -10, 5, 0),
),
pn.widgets.TooltipIcon(
value="If true, the values on the y axis are replaced by y/x."
)
),
pn.pane.HTML("<label>Group By</label>", margin=(5, 0, -5, 0)),
pn.widgets.RadioButtonGroup.from_param(
self.param.group_by,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.FloatInput.from_param(
self.param.replace_zero,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.IntSlider.from_param(
self.param.marker_size,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.FloatSlider.from_param(
self.param.marker_fill_alpha,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.LiteralInput.from_param(
self.param.markers,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.LiteralInput.from_param(
self.param.colors,
margin=(5, 0, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.Row(
pn.widgets.IntSlider.from_param(
self.param.legend_width,
margin=(5, -10, 5, 0),
min_width=100,
sizing_mode="stretch_width"
),
pn.widgets.TooltipIcon(
value="The legend with does not adjust reactively, use this slider to adjust manually."
)
)
])
def on_click_callback(self, attr, old, new, df, source):
if new:
dom = df.iloc[new[0]]['domain']
prob = df.iloc[new[0]]['problem']
algs = [self.experiment_data.get_algorithm_by_id(x) for x in df.iloc[new[0]]['algs']]
source.selected.indices = []
problem_report = ProblemTable(self.experiment_data,
sizing_mode = "stretch_width", domain=dom, problem=prob, algorithms=algs)
self.add_popup(problem_report, name=f"{dom} - {prob}")
@param.depends("x_attribute", "y_attribute", "algorithm_pairs_selector.algorithm_pairs", "group_by", watch=True)
def update_data(self):
logger.debug("start updating data")
if (type(self.x_attribute) is not NumericAttribute or
type(self.y_attribute) is not NumericAttribute):
self.df = None
logger.debug("end updating data (empty)")
return
frames = []
# TODO: resetting the index leads to alphabetical order even for order by algorithm pair
index_order = ['name', 'domain', 'problem'] if self.group_by == 'name' else ['domain', 'problem', 'name']
for (xalg, yalg) in self.algorithm_pairs_selector.algorithm_pairs:
xcol = self.experiment_data.get_data(self.x_attribute, xalg).droplevel(0)[xalg.get_name()] # get_data returns a dataframe, but we only want the xalg column
ycol = self.experiment_data.get_data(self.y_attribute, yalg).droplevel(0)[yalg.get_name()]
xalg_name = xalg.get_name()
yalg_name = yalg.get_name()
name = xalg_name if xalg == yalg else f"{xalg_name} vs {yalg_name}"
algs = [xalg.id] if xalg == yalg else [xalg.id, yalg.id]
new_frame = pd.DataFrame({'x':xcol, 'y':ycol, 'name':name, 'algs': [algs]*len(xcol)}).reset_index().set_index(index_order)
frames.append(new_frame)
if len(frames) == 0:
self.df = None
else:
overall_frame = pd.concat(frames).sort_index(level=0)
overall_frame['yrel'] = overall_frame.y.div(overall_frame.x.replace(0, np.nan))
self.df = overall_frame
logger.debug("end updating data")
@param.depends("df", "x_scale", "y_scale", "relative", "marker_size", "marker_fill_alpha", "markers", "colors", "legend_width", watch=True)
def update_data_view(self):
logger.debug("start updating data view")
plot = figure(
active_scroll = "wheel_zoom", sizing_mode="stretch_both",
x_axis_type = self.x_scale, y_axis_type = self.y_scale)
if self.df is None:
logger.debug("end updating data view (empty)")
self.data_view.objects = []
return
df_copy = self.df.copy()
df_copy = df_copy.replace(0.0, self.replace_zero)
# Compute axis labels
def get_axis_label(dim):
other = "y" if dim == "x" else "x"
attribute = self.x_attribute if dim == "x" else self.y_attribute
df_inf = df_copy.replace(np.nan, np.inf)
dim_failed = df_inf[(df_inf[dim] == np.inf)]
dim_failed_other_succ = dim_failed[(dim_failed[other] != np.inf)]
dim_less = df_inf[(df_inf[dim]-df_inf[other] < 0)]
dim_less_other_succ = dim_less[(dim_less[other] != np.inf)]
return (f"{attribute.name}\n"
f"{dim}<{other}: {len(dim_less)} "
f"{dim}<{other}, {other} not failed: {len(dim_less_other_succ)} "
f"{dim} failed: {len(dim_failed)} "
f"{dim} failed,{other} not failed: {len(dim_failed_other_succ)}")
plot.xaxis.axis_label = get_axis_label("x")
plot.yaxis.axis_label = get_axis_label("y")
# Compute failed values and replace NaN with failed.
def get_failed(df, scale):
max_val = np.nanmax(df.replace(np.inf, np.nan).values)
if scale == "log":
return int(10 ** math.ceil(math.log10(max_val)))
else:
return max_val*1.1
# TODO: it might be better to precompute the attribute wide max value once when loading the experiment data
x_failed_val = get_failed(self.experiment_data.data.loc[self.x_attribute.name], self.x_scale)
y_failed_val = get_failed(self.experiment_data.data.loc[self.y_attribute.name], self.y_scale)
df_copy["x"] = df_copy["x"].fillna(x_failed_val)
# yrel needs to be computed right here, i.e. after we replace the x NaN
# values and before we replace the y NaN values. It ensures the value
# for yrel will be yrel_failed_val if y failed, and x_failed_val/y if x failed
# but y did not fail.
df_copy["yrel"] = df_copy["y"].astype("float64").div(df_copy["x"].astype("float64"))
yrel_failed_val = get_failed(df_copy["yrel"], self.y_scale)
df_copy["y"] = df_copy["y"].fillna(y_failed_val)
df_copy["yrel"] = df_copy["yrel"].fillna(yrel_failed_val)
x="x"
y="y"
if self.relative:
y = "yrel"
y_failed_val = yrel_failed_val
# Drop all non-positive coordinates if we have log axes.
if self.x_scale == "log":
df_copy = df_copy[~(df_copy[x] <= 0)]
if self.y_scale == "log":
df_copy = df_copy[~(df_copy[y] <= 0)]
# Drop all points where y_rel is infinity if we plot yrel.
if y == "yrel":
df_copy = df_copy[(df_copy[y] != np.inf)]
size_diff = len(self.df)-len(df_copy)
if len(df_copy) == 0:
self.user_logger.log(logging.WARNING,
"All points have been dropped due to non-positive values in log "
"plots or infinite values in relative y.")
self.data_view.objects = []
return
elif size_diff > 0:
self.user_logger.log(logging.INFO,
f"Dropped {size_diff} points due to non-positive values in log "
"plots or infinite values in relative y.")
plot.x_range = Range1d(df_copy[x].min()*0.9, df_copy[x].max()*1.1)
plot.y_range = Range1d(df_copy[y].min()*0.9, df_copy[y].max()*1.1)
indices = df_copy.index.get_level_values(0).unique()
legend_items = []
for i, index in enumerate(indices):
p_df = df_copy.loc[[index]].reset_index()
p = plot.scatter(x=x, y=y, source=p_df,
line_color=self.colors[i%len(self.colors)], marker=self.markers[i%len(self.markers)],
fill_color=self.colors[i%len(self.colors)], fill_alpha=self.marker_fill_alpha,
size=self.marker_size, muted_fill_alpha = min(0.1,self.marker_fill_alpha))
p.data_source.selected.on_change('indices', partial(self.on_click_callback, df=p_df, source=p.data_source))
legend_items.append(LegendItem(label=index, renderers = [plot.renderers[i]]))
# helper lines
plot.renderers.extend([Span(location=x_failed_val, dimension='height', line_color='red')])
plot.renderers.extend([Span(location=y_failed_val, dimension='width', line_color='red')])
plot.xaxis.major_label_overrides = {x_failed_val : "failed"}
plot.yaxis.major_label_overrides = {y_failed_val : "failed"}
if self.relative:
plot.line(x=[df_copy[x].min()*0.9, x_failed_val], y=[1,1], color='black')
else:
min_max = [min(df_copy[x].min()*0.9, df_copy[y].min()*0.9), max(x_failed_val, y_failed_val)]
plot.line(x=min_max, y=min_max, color='black')
# compute appropriate number of columns and height of legend
indices_length = [len(i) for i in indices]
ncols = 1
for i in range(len(indices)):
ncols += 1
nrows = math.ceil(len(indices)/ncols)
if nrows*(ncols-1) >= len(indices): #no rows gained
continue
max_num_chars_per_column = [max(indices_length[x*nrows:min((x+1)*nrows, len(indices))]) for x in range(ncols)]
width = sum([7*x+20 for x in max_num_chars_per_column])+20
if (width > self.legend_width):
ncols -= 1
break
# legend
legend = Legend(items = legend_items, location="center")
legend.click_policy="mute"
plot.add_layout(legend, "below")
plot.legend.ncols = ncols
# hover info
plot.add_tools(HoverTool(tooltips=[
('Domain', '@domain'),
('Problem', '@problem'),
('Name', '@name'),
('x', '@x'),
('y', '@y'),
('yrel', '@yrel'),
]))
plot.add_tools(TapTool())
logger.debug("end __panel__")
self.data_view.objects = [plot]
# TODO: figure out if we can do this more directly
@param.depends("algorithm_pairs_selector.algorithm_pairs", watch=True)
def set_aps_config(self):
self.aps_config = self.algorithm_pairs_selector.get_params()
def get_watchers_for_param_config(self):
return [
"x_attribute",
"y_attribute",
"aps_config",
"x_scale",
"y_scale",
"relative",
"group_by",
"marker_size",
"marker_fill_alpha",
"markers",
"colors",
"legend_width"
]
def get_param_config_dict(self):
d = {}
if type(self.x_attribute) is NumericAttribute:
d["xattr"] = self.x_attribute.id
if type(self.y_attribute) is NumericAttribute:
d["yattr"] = self.y_attribute.id
if self.aps_config != self.param.aps_config.default:
d["aps"] = self.aps_config
if self.x_scale != self.param.x_scale.default:
d["xsc"] = self.x_scale
if self.y_scale != self.param.y_scale.default:
d["ysc"] = self.y_scale
if self.relative != self.param.relative.default:
d["rel"] = self.relative
if self.group_by != self.param.group_by.default:
d["grp"] = self.group_by
if self.replace_zero != self.param.replace_zero.default:
d["rep0"] = self.replace_zero
if self.marker_size != self.param.marker_size.default:
d["m_si"] = self.marker_size
if self.marker_fill_alpha != self.param.marker_fill_alpha.default:
d["m_al"] = self.marker_fill_alpha
if self.markers != self.param.markers.default:
d["mrks"] = self.markers
if self.colors != self.param.colors.default:
d["clrs"] = self.colors
if self.legend_width != self.param.legend_width.default:
d["legw"] = self.legend_width
return d
def set_params_from_param_config_dict(self, d):
if "aps" in d:
self.algorithm_pairs_selector.set_params(d["aps"])
update = {}
if "xattr" in d:
update["x_attribute"] = self.experiment_data.get_numeric_attribute_by_position(d["xattr"])
if "yattr" in d:
update["y_attribute"] = self.experiment_data.get_numeric_attribute_by_position(d["yattr"])
if "xsc" in d:
update["x_scale"] = d["xsc"]
if "ysc" in d:
update["y_scale"] = d["ysc"]
if "rel" in d:
update["relative"] = d["rel"]
if "grp" in d:
update["group_by"] = d["grp"]
if "rep0" in d:
update["replace_zero"] = d["rep0"]
if "m_si" in d:
update["marker_size"] = d["m_si"]
if "m_al" in d:
update["marker_fill_alpha"] = d["m_al"]
if "mrks" in d:
update["markers"] = d["mrks"]
if "clrs" in d:
update["colors"] = d["clrs"]
if "legw" in d:
update["legend_width"] = d["legw"]
self.param.update(update)