|
10 | 10 |
|
11 | 11 | import contextlib |
12 | 12 | import copy |
| 13 | +import dataclasses |
13 | 14 | import datetime |
14 | 15 | import inspect |
15 | 16 | import logging |
| 17 | +import math |
16 | 18 | import typing |
| 19 | +from collections import Counter |
17 | 20 | from dataclasses import dataclass, field |
18 | 21 | from typing import Generator, Generic, TypeVar |
19 | 22 |
|
@@ -55,16 +58,48 @@ class ExperimentStats: |
55 | 58 | When Experiment was first dispatched and started running. |
56 | 59 | finish_time: `datetime.datetime` |
57 | 60 | When Experiment reached terminating condition and stopped running. |
58 | | - duration: `datetime.timedelta` |
| 61 | + elapsed_time: `datetime.timedelta` |
59 | 62 | 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) |
60 | 77 | """ |
61 | 78 |
|
62 | 79 | trials_completed: int |
63 | 80 | best_trials_id: int |
64 | 81 | best_evaluation: float |
65 | 82 | start_time: datetime.datetime |
66 | 83 | 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 | + } |
68 | 103 |
|
69 | 104 |
|
70 | 105 | # pylint: disable=too-many-public-methods |
@@ -610,40 +645,121 @@ def configuration(self) -> ExperimentConfig: |
610 | 645 |
|
611 | 646 | return copy.deepcopy(config) |
612 | 647 |
|
| 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 |
613 | 672 | @property |
614 | 673 | def stats(self): |
615 | 674 | """Calculate :py:class:`orion.core.worker.experiment.ExperimentStats` for this particular |
616 | 675 | experiment. |
617 | 676 | """ |
| 677 | + trials = self.fetch_trials(with_evc_tree=False) |
618 | 678 | completed_trials = self.fetch_trials_by_status("completed") |
619 | 679 |
|
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 |
623 | 685 | 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) |
628 | 687 | 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 | + ) |
639 | 739 |
|
640 | 740 | return ExperimentStats( |
641 | | - trials_completed=trials_completed, |
| 741 | + trials_completed=len(completed_trials), |
642 | 742 | best_trials_id=best_trials_id, |
643 | 743 | best_evaluation=best_evaluation, |
644 | 744 | start_time=start_time, |
645 | 745 | 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 | + ), |
647 | 763 | ) |
648 | 764 |
|
649 | 765 | def __repr__(self): |
|
0 commit comments