Skip to content

Commit 313517e

Browse files
Merge branch 'dev' into Layer-2
2 parents 854af79 + 9e97cc7 commit 313517e

8 files changed

Lines changed: 235 additions & 3 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright 2017-present, The Visdom Authors
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
*/
9+
10+
before(() => {
11+
cy.visit('/');
12+
});
13+
14+
describe('Parallel Coordinates Pane', () => {
15+
it('parallel_coordinates_basic', () => {
16+
cy.run('plot_special_parallel_coordinates');
17+
18+
cy.get('.layout .window').should('have.length', 1);
19+
20+
cy.get('.content').contains('Experiment Comparison');
21+
});
22+
23+
it('parallel_coordinates_close', () => {
24+
cy.run('plot_special_parallel_coordinates');
25+
cy.get('.layout .react-grid-item')
26+
.first()
27+
.find('button[title="close"]')
28+
.click();
29+
cy.get('.layout .window').should('have.length', 0);
30+
});
31+
});

example/components/plot_special.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,47 @@ def plot_special_graph(viz, env, args):
6161
)
6262

6363

64+
# parallel coordinates plot
65+
def plot_special_parallel_coordinates(viz, env, args):
66+
n = 20
67+
X = np.column_stack(
68+
[
69+
np.random.uniform(1e-4, 0.1, n),
70+
np.random.choice([16, 32, 64, 128, 256], n).astype(float),
71+
np.random.randint(10, 200, n).astype(float),
72+
np.random.uniform(0.0, 0.5, n),
73+
np.random.uniform(1e-5, 1e-2, n),
74+
np.random.choice([0, 1, 2], n).astype(float),
75+
np.random.choice([64, 128, 256, 512], n).astype(float),
76+
np.random.uniform(50, 99, n),
77+
np.random.uniform(0.01, 1.0, n),
78+
np.random.uniform(0.4, 0.95, n),
79+
]
80+
)
81+
viz.parallel_coordinates(
82+
X=X,
83+
Y=X[:, 7],
84+
env=env,
85+
opts=dict(
86+
dimensions=[
87+
"LR",
88+
"Batch",
89+
"Epochs",
90+
"Dropout",
91+
"WD",
92+
"Optim",
93+
"Hidden",
94+
"Acc",
95+
"Loss",
96+
"F1",
97+
],
98+
title="Experiment Comparison",
99+
tickvals={5: [0, 1, 2]},
100+
ticktext={5: ["SGD", "Adam", "AdamW"]},
101+
),
102+
)
103+
104+
64105
# sankey (flow) diagram
65106
def plot_special_sankey(viz, env, args):
66107
title = args[0] if len(args) > 0 else None

example/demo.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
plot_special_mesh,
8484
plot_special_sunburst,
8585
plot_special_graph,
86+
plot_special_parallel_coordinates,
8687
plot_special_sankey,
8788
)
8889
from components.plot_roc_pr import (
@@ -214,6 +215,7 @@ def run_demo(viz, env, args):
214215
plot_special_mesh(viz, env, args)
215216
plot_special_sunburst(viz, env, args)
216217
plot_special_graph(viz, env, args)
218+
plot_special_parallel_coordinates(viz, env, args)
217219
plot_special_sankey(viz, env, args)
218220

219221
# ============ #

py/tests/test_storage_wiring.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ def write_message(self, msg):
325325

326326

327327
class TestReadHelperWiring(unittest.TestCase):
328-
"""PR #7: ``load_env`` reads a cold env through the store, not raw env_path."""
328+
"""``load_env`` reads a cold env through the store, not raw env_path."""
329329

330330
def setUp(self):
331331
self._tmp = tempfile.TemporaryDirectory()

py/visdom/__init__.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4010,6 +4010,156 @@ def graph(
40104010
{"data": data, "win": win, "eid": env, "opts": opts}, endpoint="events"
40114011
)
40124012

4013+
@pytorch_wrap
4014+
def parallel_coordinates(self, X, Y=None, win=None, env=None, opts=None):
4015+
"""
4016+
This function draws a parallel coordinates plot for comparing multiple
4017+
experiments across different parameters. Each row in the `NxM` matrix
4018+
`X` represents one experiment and each column represents a dimension
4019+
(e.g., a hyperparameter or metric).
4020+
4021+
An optional `N`-length vector `Y` supplies per-experiment color values
4022+
(e.g., accuracy or loss) so that the lines are shaded according to
4023+
a continuous colorscale.
4024+
4025+
The following `opts` are supported:
4026+
4027+
- `opts.dimensions`: `list` of dimension label strings (length M)
4028+
- `opts.colormap`: colorscale name (default `'Electric'`)
4029+
- `opts.reversescale`: reverse the colorscale direction (`bool`)
4030+
- `opts.ranges`: `dict` mapping dimension index to `[min, max]`
4031+
- `opts.constraintranges`: `dict` mapping dimension index to `[min, max]`
4032+
preset filter ranges
4033+
- `opts.tickvals`: `dict` mapping dimension index to a `list` of
4034+
tick positions
4035+
- `opts.ticktext`: `dict` mapping dimension index to a `list` of
4036+
tick labels (requires matching `tickvals`)
4037+
- `opts.max_experiments`: `int`, cap to top N experiments sorted by `Y`
4038+
descending (requires `Y`)
4039+
- `opts.title`: plot title
4040+
"""
4041+
opts = {} if opts is None else opts
4042+
_title2str(opts)
4043+
_assert_opts(opts)
4044+
4045+
X = np.asarray(X, dtype=float)
4046+
assert X.ndim == 2, "X must be a 2D matrix (N experiments x M dimensions)"
4047+
N, M = X.shape
4048+
assert N >= 1, "X must have at least 1 experiment (row)"
4049+
assert M >= 2, "X must have at least 2 dimensions (columns)"
4050+
4051+
if Y is not None:
4052+
Y = np.squeeze(np.asarray(Y, dtype=float))
4053+
assert Y.ndim == 1, "Y must be a 1D vector"
4054+
assert (
4055+
len(Y) == N
4056+
), "Length of Y (%d) must match number of rows in X (%d)" % (len(Y), N)
4057+
4058+
max_exp = opts.get("max_experiments")
4059+
if max_exp is not None:
4060+
assert Y is not None, "opts.max_experiments requires Y to be provided"
4061+
if N > max_exp:
4062+
top_idx = np.argsort(Y)[::-1][:max_exp]
4063+
X = X[top_idx]
4064+
Y = Y[top_idx]
4065+
N = len(Y)
4066+
4067+
dim_labels = opts.get("dimensions")
4068+
if dim_labels is None:
4069+
dim_labels = ["Dim %d" % (i + 1) for i in range(M)]
4070+
assert isinstance(dim_labels, (list, tuple)), (
4071+
"opts.dimensions should be a list/tuple of length %d" % M
4072+
)
4073+
assert (
4074+
len(dim_labels) == M
4075+
), "Length of opts.dimensions (%d) must match number of columns in X (%d)" % (
4076+
len(dim_labels),
4077+
M,
4078+
)
4079+
4080+
opt_ranges = opts.get("ranges") or {}
4081+
opt_constraints = opts.get("constraintranges") or {}
4082+
opt_tickvals = opts.get("tickvals") or {}
4083+
opt_ticktext = opts.get("ticktext") or {}
4084+
4085+
dimensions = []
4086+
for i in range(M):
4087+
col = X[:, i]
4088+
col_min, col_max = float(np.nanmin(col)), float(np.nanmax(col))
4089+
pad = (col_max - col_min) * 0.05 if col_max > col_min else 0.5
4090+
dim = {
4091+
"values": col.tolist(),
4092+
"label": str(dim_labels[i]),
4093+
"range": opt_ranges.get(i, [col_min - pad, col_max + pad]),
4094+
}
4095+
if i in opt_constraints:
4096+
dim["constraintrange"] = opt_constraints[i]
4097+
if i in opt_tickvals:
4098+
dim["tickvals"] = opt_tickvals[i]
4099+
if i in opt_ticktext:
4100+
assert (
4101+
i in opt_tickvals
4102+
), "opts.ticktext[%d] requires matching opts.tickvals[%d]" % (i, i)
4103+
assert len(opt_ticktext[i]) == len(opt_tickvals[i]), (
4104+
"opts.ticktext[%d] and opts.tickvals[%d] must have the same length"
4105+
% (i, i)
4106+
)
4107+
dim["ticktext"] = opt_ticktext[i]
4108+
dimensions.append(dim)
4109+
4110+
line_config = {}
4111+
if Y is not None:
4112+
line_config = {
4113+
"color": Y.tolist(),
4114+
"colorscale": opts.get("colormap", "Electric"),
4115+
"showscale": True,
4116+
"cmin": float(np.nanmin(Y)),
4117+
"cmax": float(np.nanmax(Y)),
4118+
}
4119+
if opts.get("reversescale"):
4120+
line_config["reversescale"] = True
4121+
4122+
title = opts.get("title")
4123+
trace = {
4124+
"type": "parcoords",
4125+
"dimensions": dimensions,
4126+
"line": line_config if line_config else None,
4127+
"labelfont": {"size": 13},
4128+
"tickfont": {"size": 11},
4129+
"rangefont": {"size": 11},
4130+
}
4131+
if title:
4132+
trace["domain"] = {"y": [0, 0.85]}
4133+
4134+
data = [_scrub_dict(trace)]
4135+
4136+
layout = _opts2layout(opts)
4137+
layout.setdefault("paper_bgcolor", "white")
4138+
layout.setdefault("plot_bgcolor", "white")
4139+
if title:
4140+
layout["title"] = {
4141+
"text": str(title),
4142+
"x": 0.5,
4143+
"xanchor": "center",
4144+
}
4145+
4146+
has_colorbar = bool(line_config)
4147+
if "width" not in opts:
4148+
colorbar_room = 100 if has_colorbar else 0
4149+
opts["width"] = max(600, M * 140 + colorbar_room)
4150+
if "height" not in opts:
4151+
opts["height"] = 450
4152+
4153+
return self._send(
4154+
{
4155+
"data": data,
4156+
"win": win,
4157+
"eid": env,
4158+
"layout": _scrub_dict(layout),
4159+
"opts": opts,
4160+
}
4161+
)
4162+
40134163
@pytorch_wrap
40144164
def violin(self, X, win=None, env=None, opts=None):
40154165
"""

py/visdom/__init__.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,14 @@ class Visdom:
297297
env: _OptStr = ...,
298298
opts: _OptOps = ...,
299299
) -> _SendReturn: ...
300+
def parallel_coordinates(
301+
self,
302+
X: Tensor,
303+
Y: Optional[Tensor] = ...,
304+
win: _OptStr = ...,
305+
env: _OptStr = ...,
306+
opts: _OptOps = ...,
307+
) -> _SendReturn: ...
300308
def violin(
301309
self,
302310
X: Tensor,

py/visdom/server/handlers/web_handlers.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,6 @@ def post(self, args):
573573
escape_eid(args),
574574
self.subs[sid],
575575
self.storage,
576-
env_path=self.env_path,
577576
)
578577
except ValueError as e:
579578
notify(

py/visdom/utils/server_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import logging
2222
import os
2323
import time
24+
import errno
2425
from collections import OrderedDict
2526

2627
MAX_ENV_NAME_LEN = 25
@@ -458,7 +459,7 @@ def send_to_sources(handler, msg):
458459
source.write_message(json.dumps(msg, cls=NanSafeEncoder))
459460

460461

461-
def load_env(state, eid, socket, store, env_path=DEFAULT_ENV_PATH):
462+
def load_env(state, eid, socket, store):
462463
"""load an environment to a client by socket"""
463464
env = {}
464465
if eid in state:

0 commit comments

Comments
 (0)