Skip to content

Commit f6ba53b

Browse files
authored
Merge pull request #1038 from notoraptor/experiment-progress-bar-backend
Improve experiment stats
2 parents 8b424cc + babbf8b commit f6ba53b

12 files changed

Lines changed: 717 additions & 41 deletions

File tree

docs/src/user/web_api.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,57 @@ retrieve individual experiments as well as a list of all your experiments.
161161
:statuscode 400: When an invalid query parameter is passed in the request.
162162
:statuscode 404: When the specified experiment doesn't exist in the database.
163163

164+
.. http:get:: /experiments/status/:name
165+
166+
Retrieve the stats of the existing experiment named ``name``.
167+
168+
**Example response**
169+
170+
.. sourcecode:: http
171+
172+
HTTP/1.1 200 OK
173+
Content-Type: text/javascript
174+
175+
.. code-block:: json
176+
177+
{
178+
"trials_completed": 40,
179+
"best_trials_id": "955c77e7f567c2625f48546188a6cda1",
180+
"best_evaluation": -0.788720013597263,
181+
"start_time": "2019-11-25 16:02:02.872583",
182+
"finish_time": "2019-11-27 21:13:27.043519",
183+
"max_trials": 40,
184+
"nb_trials": 40,
185+
"progress": 1,
186+
"trial_status_count": {
187+
"completed": 40
188+
},
189+
"elapsed_time": "2 days, 5:11:24.006755",
190+
"sum_of_trials_time": "8 days, 23:15:15.594405",
191+
"eta": "0:00:00",
192+
"eta_milliseconds": 0
193+
}
194+
195+
:query version: Optional version of the experiment to retrieve. If unspecified, the latest
196+
version of the experiment is retrieved.
197+
198+
:>json trials_completed: The number of trials completed.
199+
:>json best_trial_id: The best trial ID.
200+
:>json best_evaluation: Best evaluation.
201+
:>json start_time: The timestamp when the experiment started.
202+
:>json finish_time: The timestamp when the experiment finished.
203+
:>json max_trials: The number of max trials for this experiment.
204+
:>json nb_trials: The current number of trials in this experiment.
205+
:>json progress: Floating value between 0 and 1 representing experiment progression.
206+
:>json trial_status_count: A dictionary mapping trial status to number of trials with this status in the experiment.
207+
:>json elapsed_time: The time elapsed since experiment started.
208+
:>json sum_of_trials_time: The sum of trials execution times.
209+
:>json eta: The estimation of remaining time for experiment to finish.
210+
:>json eta_milliseconds: The ETA in milliseconds (convenient for usages in Javascript).
211+
212+
:statuscode 400: When an invalid query parameter is passed in the request.
213+
:statuscode 404: When the specified experiment doesn't exist in the database.
214+
164215
Trials
165216
------
166217

src/orion/core/utils/format_terminal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ def format_refers(experiment):
369369
{best_params}
370370
start time: {stats.start_time}
371371
finish time: {stats.finish_time}
372-
duration: {stats.duration}
372+
elapsed_time: {stats.elapsed_time}
373373
"""
374374

375375

@@ -391,7 +391,7 @@ def format_stats(experiment):
391391
392392
"""
393393
stats = experiment.stats
394-
if not stats:
394+
if not stats.trials_completed:
395395
return NO_STATS_TEMPLATE.format(title=format_title("Stats"))
396396

397397
best_params = get_trial_params(stats.best_trials_id, experiment)

src/orion/core/worker/experiment.py

Lines changed: 137 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010

1111
import contextlib
1212
import copy
13+
import dataclasses
1314
import datetime
1415
import inspect
1516
import logging
17+
import math
1618
import typing
19+
from collections import Counter
1720
from dataclasses import dataclass, field
1821
from typing import Generator, Generic, TypeVar
1922

@@ -55,16 +58,48 @@ class ExperimentStats:
5558
When Experiment was first dispatched and started running.
5659
finish_time: `datetime.datetime`
5760
When Experiment reached terminating condition and stopped running.
58-
duration: `datetime.timedelta`
61+
elapsed_time: `datetime.timedelta`
5962
Elapsed time.
63+
max_trials: int
64+
Experiment max_trials
65+
nb_trials: int
66+
Number of trials in experiment
67+
progress: float
68+
Experiment progression (between 0 and 1).
69+
trial_status_count: Dict[str, int]
70+
Dictionary mapping trial status to number of trials that have this status
71+
sum_of_trials_time: `datetime.timedelta`
72+
Sum of trial duration
73+
eta: `datetime.timedelta`
74+
Estimated remaining time
75+
eta_milliseconds: float
76+
ETA in milliseconds (used to get ETA in other programming languages, e.g. Javascript)
6077
"""
6178

6279
trials_completed: int
6380
best_trials_id: int
6481
best_evaluation: float
6582
start_time: datetime.datetime
6683
finish_time: datetime.datetime
67-
duration: datetime.timedelta = field(default_factory=datetime.timedelta)
84+
max_trials: int = 0
85+
nb_trials: int = 0
86+
progress: float = 0
87+
trial_status_count: dict = field(default_factory=dict)
88+
elapsed_time: datetime.timedelta = field(default_factory=datetime.timedelta)
89+
sum_of_trials_time: datetime.timedelta = field(default_factory=datetime.timedelta)
90+
eta: datetime.timedelta = field(default_factory=datetime.timedelta)
91+
eta_milliseconds: float = 0
92+
93+
def to_json(self):
94+
"""Return a JSON-compatible dictionary of stats."""
95+
return {
96+
key: (
97+
str(value)
98+
if isinstance(value, (datetime.datetime, datetime.timedelta))
99+
else value
100+
)
101+
for key, value in dataclasses.asdict(self).items()
102+
}
68103

69104

70105
# pylint: disable=too-many-public-methods
@@ -610,40 +645,121 @@ def configuration(self) -> ExperimentConfig:
610645

611646
return copy.deepcopy(config)
612647

648+
@property
649+
def progress(self) -> float:
650+
"""Return a floating number between 0 and 1 representing experiment progress,
651+
or None if progress cannot be completed."""
652+
653+
trials = self.fetch_trials(with_evc_tree=False)
654+
completed_trials = self.fetch_trials_by_status("completed")
655+
broken_trials = self.fetch_trials_by_status("broken")
656+
657+
if self.max_trials is None or math.isinf(self.max_trials):
658+
progress = None
659+
elif len(completed_trials) > self.max_trials:
660+
progress = 1.0
661+
else:
662+
nb_trials_to_complete = max(self.max_trials, len(trials)) - len(
663+
broken_trials
664+
)
665+
if nb_trials_to_complete == 0:
666+
progress = None
667+
else:
668+
progress = len(completed_trials) / nb_trials_to_complete
669+
return progress
670+
671+
# pylint:disable=too-many-branches
613672
@property
614673
def stats(self):
615674
"""Calculate :py:class:`orion.core.worker.experiment.ExperimentStats` for this particular
616675
experiment.
617676
"""
677+
trials = self.fetch_trials(with_evc_tree=False)
618678
completed_trials = self.fetch_trials_by_status("completed")
619679

620-
if not completed_trials:
621-
return {}
622-
trials_completed = len(completed_trials)
680+
# Retrieve the best evaluation, best trial ID, start time and finish time
681+
# TODO: should we compute finish time as min(completed_trials.start_time)
682+
# instead of metadata["datetime"]?
683+
# For elapsed time below, we do not use metadata["datetime"]
684+
best_evaluation = None
623685
best_trials_id = None
624-
trial = completed_trials[0]
625-
best_evaluation = trial.objective.value
626-
best_trials_id = trial.id
627-
start_time = self.metadata["datetime"]
686+
start_time = self.metadata.get("datetime", None)
628687
finish_time = start_time
629-
for trial in completed_trials:
630-
# All trials are going to finish certainly after the start date
631-
# of the experiment they belong to
632-
if trial.end_time > finish_time: # pylint:disable=no-member
633-
finish_time = trial.end_time
634-
objective = trial.objective.value
635-
if objective < best_evaluation:
636-
best_evaluation = objective
637-
best_trials_id = trial.id
638-
duration = finish_time - start_time
688+
if start_time and completed_trials:
689+
trial = completed_trials[0]
690+
best_evaluation = trial.objective.value
691+
best_trials_id = trial.id
692+
for trial in completed_trials:
693+
# All trials are going to finish certainly after the start date
694+
# of the experiment they belong to
695+
if trial.end_time > finish_time: # pylint:disable=no-member
696+
finish_time = trial.end_time
697+
objective = trial.objective.value
698+
if objective < best_evaluation:
699+
best_evaluation = objective
700+
best_trials_id = trial.id
701+
702+
# Compute elapsed time using all finished/stopped/running experiments
703+
# i.e. all trials that have an execution interval
704+
# (from a start time to an end time or heartbeat)
705+
intervals = []
706+
for trial in trials:
707+
interval = trial.execution_interval
708+
if interval:
709+
intervals.append(interval)
710+
if intervals:
711+
min_start_time = min(interval[0] for interval in intervals)
712+
max_end_time = max(interval[1] for interval in intervals)
713+
elapsed_time = max_end_time - min_start_time
714+
else:
715+
elapsed_time = datetime.timedelta()
716+
717+
# Compute ETA
718+
if not self.max_trials or math.isinf(self.max_trials):
719+
# If max_trials is None, 0 or infinite, we cannot compute ETA
720+
eta = None
721+
elif len(completed_trials) > self.max_trials:
722+
# If there are more completed trials than max trials, then ETA should be 0
723+
eta = datetime.timedelta()
724+
elif not completed_trials:
725+
# If there are no completed trials, then we set ETA to infinite
726+
# NB: float("inf") may lead to wrong JSON syntax, so we just write "infinite"
727+
eta = "infinite"
728+
else:
729+
# Compute ETA using duration of completed trials
730+
completed_intervals = [
731+
trial.execution_interval for trial in completed_trials
732+
]
733+
min_start_time = min(interval[0] for interval in completed_intervals)
734+
max_end_time = max(interval[1] for interval in completed_intervals)
735+
completed_duration = max_end_time - min_start_time
736+
eta = (completed_duration / len(completed_trials)) * (
737+
self.max_trials - len(completed_trials)
738+
)
639739

640740
return ExperimentStats(
641-
trials_completed=trials_completed,
741+
trials_completed=len(completed_trials),
642742
best_trials_id=best_trials_id,
643743
best_evaluation=best_evaluation,
644744
start_time=start_time,
645745
finish_time=finish_time,
646-
duration=duration,
746+
elapsed_time=elapsed_time,
747+
sum_of_trials_time=sum(
748+
(trial.duration for trial in trials),
749+
datetime.timedelta(),
750+
),
751+
nb_trials=len(trials),
752+
eta=eta,
753+
eta_milliseconds=eta.total_seconds() * 1000
754+
if isinstance(eta, datetime.timedelta)
755+
else None,
756+
trial_status_count={**Counter(trial.status for trial in trials)},
757+
progress=self.progress,
758+
max_trials=(
759+
"infinite"
760+
if self.max_trials is not None and math.isinf(self.max_trials)
761+
else self.max_trials
762+
),
647763
)
648764

649765
def __repr__(self):

src/orion/core/worker/trial.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import logging
1414
import os
1515
import warnings
16+
from datetime import timedelta
1617

1718
from orion.core.utils.exceptions import InvalidResult
1819
from orion.core.utils.flatten import unflatten
@@ -477,6 +478,26 @@ def full_name(self):
477478
)
478479
return self.format_values(self._params, sep="-").replace("/", ".")
479480

481+
@property
482+
def duration(self):
483+
"""Return trial duration as a timedelta() object"""
484+
execution_interval = self.execution_interval
485+
if execution_interval:
486+
from_time, to_time = execution_interval
487+
return to_time - from_time
488+
else:
489+
return timedelta()
490+
491+
@property
492+
def execution_interval(self):
493+
"""Return execution interval, or None if unavailable"""
494+
if self.start_time:
495+
if self.end_time:
496+
return self.start_time, self.end_time
497+
elif self.heartbeat:
498+
return self.start_time, self.heartbeat
499+
return None
500+
480501
def _repr_values(self, values, sep=","):
481502
"""Represent with a string the given values."""
482503
return Trial.format_values(values, sep)

src/orion/serving/experiments_resource.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ def on_get_experiment(self, req: Request, resp: Response, name: str):
4949
response = build_experiment_response(experiment, status, algorithm, best_trial)
5050
resp.body = json.dumps(response)
5151

52+
def on_get_experiment_status(self, req: Request, resp: Response, name: str):
53+
"""
54+
Handle GET requests for experiments/status/:name where `name` is
55+
the user-defined name of the experiment
56+
"""
57+
verify_query_parameters(req.params, ["version"])
58+
version = req.get_param_as_int("version")
59+
experiment = retrieve_experiment(self.storage, name, version)
60+
resp.body = json.dumps(experiment.stats.to_json())
61+
5262

5363
def _find_latest_versions(experiments):
5464
"""Find the latest versions of the experiments"""
@@ -86,7 +96,7 @@ def _retrieve_algorithm(experiment: Experiment) -> dict:
8696

8797
def _retrieve_best_trial(experiment: Experiment) -> Optional[Trial]:
8898
"""Constructs the view of the best trial if there is one"""
89-
if not experiment.stats:
99+
if not experiment.stats.trials_completed:
90100
return None
91101

92102
return experiment.get_trial(uid=experiment.stats.best_trials_id)

src/orion/serving/responses.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def build_trial_response(trial: Trial) -> dict:
4242
"statistics": {
4343
statistic.name: statistic.value for statistic in trial.statistics
4444
},
45+
"status": trial.status,
4546
}
4647

4748

@@ -87,7 +88,7 @@ def build_experiment_response(
8788
}
8889

8990
stats = experiment.stats
90-
if stats:
91+
if stats.trials_completed:
9192
data["trialsCompleted"] = stats.trials_completed
9293
data["startTime"] = str(stats.start_time)
9394
data["endTime"] = str(stats.finish_time)

src/orion/serving/webapi.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ def __init__(self, storage, config=None):
120120
self.add_route("/experiments/{name}", experiments_resource, suffix="experiment")
121121
self.add_route("/benchmarks", benchmarks_resource)
122122
self.add_route("/benchmarks/{name}", benchmarks_resource, suffix="benchmark")
123+
self.add_route(
124+
"/experiments/status/{name}",
125+
experiments_resource,
126+
suffix="experiment_status",
127+
)
123128
self.add_route(
124129
"/trials/{experiment_name}", trials_resource, suffix="trials_in_experiment"
125130
)

0 commit comments

Comments
 (0)