Skip to content

Commit a56c8bb

Browse files
authored
Experiments proof of concept (iterative#4199)
* experiments: add initial experiments.show * only includes metrics + params (no code/data information) * experiments: basic `dvc experiments show` functionality * experiments: clone and run repro inside clone workspace * repro: add -e/--experiment option * experiments: hash experiments to identify duplicates * experiments: update show * experiments: checkout experiment after running repro * experiments: add `dvc experiments checkout` * experiments: add simple `dvc experiments diff` command * just shows combined output from `metrics diff` and `plots diff` for now * add very simple tests * add some useful output messages * experiments: suppress help messages while feature is in development * experiment related commands will be accessible but hidden from the default command help messages * add simple experiments command tests * experiments: disable feature by default * experiments only enabled in test environment (DVC_TEST) or when core.experiments config option is true * use fetch on checkout missing revs
1 parent 4edf368 commit a56c8bb

17 files changed

+806
-1
lines changed

.dvc/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
/pkg
1010
/repos
1111
/tmp
12+
/experiments

dvc/cli.py

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
data_sync,
1616
destroy,
1717
diff,
18+
experiments,
1819
freeze,
1920
gc,
2021
get,
@@ -77,6 +78,7 @@
7778
update,
7879
git_hook,
7980
plots,
81+
experiments,
8082
]
8183

8284

dvc/command/experiments.py

+358
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import argparse
2+
import io
3+
import logging
4+
from collections import OrderedDict
5+
6+
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
7+
from dvc.command.metrics import DEFAULT_PRECISION
8+
from dvc.exceptions import DvcException
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def _update_names(names, items):
14+
from flatten_json import flatten
15+
16+
for name, item in items:
17+
if isinstance(item, dict):
18+
item = flatten(item, ".")
19+
names.update(item.keys())
20+
else:
21+
names.add(name)
22+
23+
24+
def _collect_names(all_experiments):
25+
metric_names = set()
26+
param_names = set()
27+
28+
for _, experiments in all_experiments.items():
29+
for exp in experiments.values():
30+
_update_names(metric_names, exp.get("metrics", {}).items())
31+
_update_names(param_names, exp.get("params", {}).items())
32+
33+
return sorted(metric_names), sorted(param_names)
34+
35+
36+
def _collect_rows(
37+
base_rev, experiments, metric_names, param_names, precision=None
38+
):
39+
from flatten_json import flatten
40+
41+
if precision is None:
42+
precision = DEFAULT_PRECISION
43+
44+
def _round(val):
45+
if isinstance(val, float):
46+
return round(val, precision)
47+
48+
return val
49+
50+
def _extend(row, names, items):
51+
for fname, item in items:
52+
if isinstance(item, dict):
53+
item = flatten(item, ".")
54+
else:
55+
item = {fname: item}
56+
for name in names:
57+
if name in item:
58+
row.append(str(_round(item[name])))
59+
else:
60+
row.append("-")
61+
62+
for i, (rev, exp) in enumerate(experiments.items()):
63+
row = []
64+
style = None
65+
if rev == "baseline":
66+
row.append(f"{base_rev}")
67+
style = "bold"
68+
elif i < len(experiments) - 1:
69+
row.append(f"├── {rev[:7]}")
70+
else:
71+
row.append(f"└── {rev[:7]}")
72+
73+
_extend(row, metric_names, exp.get("metrics", {}).items())
74+
_extend(row, param_names, exp.get("params", {}).items())
75+
76+
yield row, style
77+
78+
79+
def _show_experiments(all_experiments, console, precision=None):
80+
from rich.table import Table
81+
from dvc.scm.git import Git
82+
83+
metric_names, param_names = _collect_names(all_experiments)
84+
85+
table = Table(row_styles=["white", "bright_white"])
86+
table.add_column("Experiment", header_style="black on grey93")
87+
for name in metric_names:
88+
table.add_column(
89+
name, justify="right", header_style="black on cornsilk1"
90+
)
91+
for name in param_names:
92+
table.add_column(
93+
name, justify="left", header_style="black on light_cyan1"
94+
)
95+
96+
for base_rev, experiments in all_experiments.items():
97+
if Git.is_sha(base_rev):
98+
base_rev = base_rev[:7]
99+
100+
for row, style, in _collect_rows(
101+
base_rev,
102+
experiments,
103+
metric_names,
104+
param_names,
105+
precision=precision,
106+
):
107+
table.add_row(*row, style=style)
108+
109+
console.print(table)
110+
111+
112+
class CmdExperimentsShow(CmdBase):
113+
def run(self):
114+
from rich.console import Console
115+
from dvc.utils.pager import pager
116+
117+
if not self.repo.experiments:
118+
return 0
119+
120+
try:
121+
all_experiments = self.repo.experiments.show(
122+
all_branches=self.args.all_branches,
123+
all_tags=self.args.all_tags,
124+
all_commits=self.args.all_commits,
125+
)
126+
127+
# Note: rich does not currently include a native way to force
128+
# infinite width for use with a pager
129+
console = Console(
130+
file=io.StringIO(), force_terminal=True, width=9999
131+
)
132+
133+
_show_experiments(all_experiments, console)
134+
135+
pager(console.file.getvalue())
136+
except DvcException:
137+
logger.exception("failed to show experiments")
138+
return 1
139+
140+
return 0
141+
142+
143+
class CmdExperimentsCheckout(CmdBase):
144+
def run(self):
145+
if not self.repo.experiments:
146+
return 0
147+
148+
self.repo.experiments.checkout(
149+
self.args.experiment, force=self.args.force
150+
)
151+
152+
return 0
153+
154+
155+
def _show_diff(
156+
diff, title="", markdown=False, no_path=False, old=False, precision=None
157+
):
158+
from dvc.utils.diff import table
159+
160+
if precision is None:
161+
precision = DEFAULT_PRECISION
162+
163+
def _round(val):
164+
if isinstance(val, float):
165+
return round(val, precision)
166+
167+
return val
168+
169+
rows = []
170+
for fname, diff_ in diff.items():
171+
sorted_diff = OrderedDict(sorted(diff_.items()))
172+
for item, change in sorted_diff.items():
173+
row = [] if no_path else [fname]
174+
row.append(item)
175+
if old:
176+
row.append(_round(change.get("old")))
177+
row.append(_round(change["new"]))
178+
row.append(_round(change.get("diff", "diff not supported")))
179+
rows.append(row)
180+
181+
header = [] if no_path else ["Path"]
182+
header.append(title)
183+
if old:
184+
header.extend(["Old", "New"])
185+
else:
186+
header.append("Value")
187+
header.append("Change")
188+
189+
return table(header, rows, markdown)
190+
191+
192+
class CmdExperimentsDiff(CmdBase):
193+
def run(self):
194+
if not self.repo.experiments:
195+
return 0
196+
197+
try:
198+
diff = self.repo.experiments.diff(
199+
a_rev=self.args.a_rev,
200+
b_rev=self.args.b_rev,
201+
all=self.args.all,
202+
)
203+
204+
if self.args.show_json:
205+
import json
206+
207+
logger.info(json.dumps(diff))
208+
else:
209+
diffs = [("metrics", "Metric"), ("params", "Param")]
210+
for key, title in diffs:
211+
table = _show_diff(
212+
diff[key],
213+
title=title,
214+
markdown=self.args.show_md,
215+
no_path=self.args.no_path,
216+
old=self.args.old,
217+
precision=self.args.precision,
218+
)
219+
if table:
220+
logger.info(table)
221+
logger.info("")
222+
223+
except DvcException:
224+
logger.exception("failed to show experiments diff")
225+
return 1
226+
227+
return 0
228+
229+
230+
def add_parser(subparsers, parent_parser):
231+
EXPERIMENTS_HELP = "Commands to display and compare experiments."
232+
233+
experiments_parser = subparsers.add_parser(
234+
"experiments",
235+
parents=[parent_parser],
236+
description=append_doc_link(EXPERIMENTS_HELP, "experiments"),
237+
formatter_class=argparse.RawDescriptionHelpFormatter,
238+
)
239+
240+
experiments_subparsers = experiments_parser.add_subparsers(
241+
dest="cmd",
242+
help="Use `dvc experiments CMD --help` to display "
243+
"command-specific help.",
244+
)
245+
246+
fix_subparsers(experiments_subparsers)
247+
248+
EXPERIMENTS_SHOW_HELP = "Print experiments."
249+
experiments_show_parser = experiments_subparsers.add_parser(
250+
"show",
251+
parents=[parent_parser],
252+
description=append_doc_link(EXPERIMENTS_SHOW_HELP, "experiments/show"),
253+
help=EXPERIMENTS_SHOW_HELP,
254+
formatter_class=argparse.RawDescriptionHelpFormatter,
255+
)
256+
experiments_show_parser.add_argument(
257+
"-a",
258+
"--all-branches",
259+
action="store_true",
260+
default=False,
261+
help="Show metrics for all branches.",
262+
)
263+
experiments_show_parser.add_argument(
264+
"-T",
265+
"--all-tags",
266+
action="store_true",
267+
default=False,
268+
help="Show metrics for all tags.",
269+
)
270+
experiments_show_parser.add_argument(
271+
"--all-commits",
272+
action="store_true",
273+
default=False,
274+
help="Show metrics for all commits.",
275+
)
276+
experiments_show_parser.set_defaults(func=CmdExperimentsShow)
277+
278+
EXPERIMENTS_CHECKOUT_HELP = "Checkout experiments."
279+
experiments_checkout_parser = experiments_subparsers.add_parser(
280+
"checkout",
281+
parents=[parent_parser],
282+
description=append_doc_link(
283+
EXPERIMENTS_CHECKOUT_HELP, "experiments/checkout"
284+
),
285+
help=EXPERIMENTS_CHECKOUT_HELP,
286+
formatter_class=argparse.RawDescriptionHelpFormatter,
287+
)
288+
experiments_checkout_parser.add_argument(
289+
"-f",
290+
"--force",
291+
action="store_true",
292+
default=False,
293+
help="Overwrite your current workspace with changes from the "
294+
"experiment.",
295+
)
296+
experiments_checkout_parser.add_argument(
297+
"experiment", help="Checkout this experiment.",
298+
)
299+
experiments_checkout_parser.set_defaults(func=CmdExperimentsCheckout)
300+
301+
EXPERIMENTS_DIFF_HELP = (
302+
"Show changes between experiments in the DVC repository."
303+
)
304+
experiments_diff_parser = experiments_subparsers.add_parser(
305+
"diff",
306+
parents=[parent_parser],
307+
description=append_doc_link(EXPERIMENTS_DIFF_HELP, "experiments/diff"),
308+
help=EXPERIMENTS_DIFF_HELP,
309+
formatter_class=argparse.RawDescriptionHelpFormatter,
310+
)
311+
experiments_diff_parser.add_argument(
312+
"a_rev", nargs="?", help="Old experiment to compare (defaults to HEAD)"
313+
)
314+
experiments_diff_parser.add_argument(
315+
"b_rev",
316+
nargs="?",
317+
help="New experiment to compare (defaults to the current workspace)",
318+
)
319+
experiments_diff_parser.add_argument(
320+
"--all",
321+
action="store_true",
322+
default=False,
323+
help="Show unchanged metrics/params as well.",
324+
)
325+
experiments_diff_parser.add_argument(
326+
"--show-json",
327+
action="store_true",
328+
default=False,
329+
help="Show output in JSON format.",
330+
)
331+
experiments_diff_parser.add_argument(
332+
"--show-md",
333+
action="store_true",
334+
default=False,
335+
help="Show tabulated output in the Markdown format (GFM).",
336+
)
337+
experiments_diff_parser.add_argument(
338+
"--old",
339+
action="store_true",
340+
default=False,
341+
help="Show old metric/param value.",
342+
)
343+
experiments_diff_parser.add_argument(
344+
"--no-path",
345+
action="store_true",
346+
default=False,
347+
help="Don't show metric/param path.",
348+
)
349+
experiments_diff_parser.add_argument(
350+
"--precision",
351+
type=int,
352+
help=(
353+
"Round metrics/params to `n` digits precision after the decimal "
354+
f"point. Rounds to {DEFAULT_PRECISION} digits by default."
355+
),
356+
metavar="<n>",
357+
)
358+
experiments_diff_parser.set_defaults(func=CmdExperimentsDiff)

dvc/command/repro.py

+8
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def run(self):
4040
downstream=self.args.downstream,
4141
recursive=self.args.recursive,
4242
force_downstream=self.args.force_downstream,
43+
experiment=self.args.experiment,
4344
)
4445

4546
if len(stages) == 0:
@@ -166,4 +167,11 @@ def add_parser(subparsers, parent_parser):
166167
default=False,
167168
help="Start from the specified stages when reproducing pipelines.",
168169
)
170+
repro_parser.add_argument(
171+
"-e",
172+
"--experiment",
173+
action="store_true",
174+
default=False,
175+
help=argparse.SUPPRESS,
176+
)
169177
repro_parser.set_defaults(func=CmdRepro)

0 commit comments

Comments
 (0)