mlflow-optimization 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mlflow_optimization/__init__.py +50 -0
- mlflow_optimization/_bootstrap.py +129 -0
- mlflow_optimization/common/diagnostics.py +19 -0
- mlflow_optimization/common/patching.py +82 -0
- mlflow_optimization/features/annotations/annotate.py +97 -0
- mlflow_optimization/features/annotations/types.py +22 -0
- mlflow_optimization/features/artifacts/policy.py +16 -0
- mlflow_optimization/features/artifacts/ports.py +17 -0
- mlflow_optimization/features/autolog/ports.py +49 -0
- mlflow_optimization/features/autolog/service.py +147 -0
- mlflow_optimization/features/autolog/types.py +31 -0
- mlflow_optimization/features/metadata/collect.py +52 -0
- mlflow_optimization/features/metadata/types.py +42 -0
- mlflow_optimization/features/metrics/normalize.py +121 -0
- mlflow_optimization/features/metrics/types.py +54 -0
- mlflow_optimization/features/outcomes/classify.py +53 -0
- mlflow_optimization/features/outcomes/types.py +40 -0
- mlflow_optimization/features/runs/ports.py +30 -0
- mlflow_optimization/features/runs/record.py +231 -0
- mlflow_optimization/features/runs/types.py +43 -0
- mlflow_optimization/integrations/cp_sat/__init__.py +5 -0
- mlflow_optimization/integrations/cp_sat/adapter.py +464 -0
- mlflow_optimization/integrations/gurobipy/__init__.py +5 -0
- mlflow_optimization/integrations/gurobipy/adapter.py +474 -0
- mlflow_optimization/integrations/mathopt/__init__.py +1 -0
- mlflow_optimization/integrations/mathopt/adapter.py +939 -0
- mlflow_optimization/integrations/mlflow/artifacts.py +44 -0
- mlflow_optimization/integrations/mlflow/runs.py +480 -0
- mlflow_optimization/integrations/pyomo/__init__.py +5 -0
- mlflow_optimization/integrations/pyomo/adapter.py +1499 -0
- mlflow_optimization/integrations/pyscipopt/__init__.py +5 -0
- mlflow_optimization/integrations/pyscipopt/adapter.py +657 -0
- mlflow_optimization/integrations/routing/__init__.py +5 -0
- mlflow_optimization/integrations/routing/adapter.py +429 -0
- mlflow_optimization/py.typed +0 -0
- mlflow_optimization-0.1.0.dist-info/METADATA +52 -0
- mlflow_optimization-0.1.0.dist-info/RECORD +40 -0
- mlflow_optimization-0.1.0.dist-info/WHEEL +4 -0
- mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-APACHE +176 -0
- mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Common rules for tracking the API, engine, problem and parameters."""
|
|
2
|
+
|
|
3
|
+
from .types import (
|
|
4
|
+
CollectedMetadata,
|
|
5
|
+
ParameterOrigin,
|
|
6
|
+
ReportedMetadata,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _format_value(value: float | str | bool) -> str:
|
|
11
|
+
"""A string representation of a recorded value."""
|
|
12
|
+
if isinstance(value, bool):
|
|
13
|
+
return str(value).lower()
|
|
14
|
+
return str(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def collect_metadata(
|
|
18
|
+
api: str, reported: ReportedMetadata | None, *, include_parameters: bool
|
|
19
|
+
) -> CollectedMetadata:
|
|
20
|
+
"""The recordable experiment conditions of a solve.
|
|
21
|
+
|
|
22
|
+
``reported`` is None when extraction failed.
|
|
23
|
+
"""
|
|
24
|
+
tags: dict[str, str] = {"optimization.api": api}
|
|
25
|
+
collected_parameters: dict[str, str] = {}
|
|
26
|
+
if reported is None:
|
|
27
|
+
tags["optimization.solver"] = "unknown"
|
|
28
|
+
return CollectedMetadata(tags=tags, params=collected_parameters)
|
|
29
|
+
|
|
30
|
+
tags["optimization.solver"] = reported.solver or "unknown"
|
|
31
|
+
if reported.api_version is not None:
|
|
32
|
+
tags["optimization.api_version"] = reported.api_version
|
|
33
|
+
if reported.solver_version is not None:
|
|
34
|
+
tags["optimization.solver_version"] = reported.solver_version
|
|
35
|
+
tags["optimization.wall_time_includes_problem_build"] = _format_value(
|
|
36
|
+
reported.wall_time_includes_problem_build
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for problem_name, problem_value in reported.problem.items():
|
|
40
|
+
collected_parameters[f"problem.{problem_name}"] = _format_value(problem_value)
|
|
41
|
+
|
|
42
|
+
if include_parameters:
|
|
43
|
+
parameter_prefixes: dict[ParameterOrigin, str] = {
|
|
44
|
+
ParameterOrigin.EXPLICIT: "solver_param.",
|
|
45
|
+
ParameterOrigin.DEFAULT: "solver_param_default.",
|
|
46
|
+
ParameterOrigin.EFFECTIVE: "solver_param_effective.",
|
|
47
|
+
}
|
|
48
|
+
for parameter in reported.parameters:
|
|
49
|
+
parameter_key: str = parameter_prefixes[parameter.origin] + parameter.name
|
|
50
|
+
collected_parameters[parameter_key] = _format_value(parameter.value)
|
|
51
|
+
|
|
52
|
+
return CollectedMetadata(tags=tags, params=collected_parameters)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Experiment conditions of a solve."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParameterOrigin(StrEnum):
|
|
9
|
+
"""Where a solver parameter value comes from."""
|
|
10
|
+
|
|
11
|
+
EXPLICIT = "explicit"
|
|
12
|
+
DEFAULT = "default"
|
|
13
|
+
EFFECTIVE = "effective"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SolverParameter:
|
|
18
|
+
"""One solver parameter value and its origin."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
value: int | float | str | bool
|
|
22
|
+
origin: ParameterOrigin = ParameterOrigin.EXPLICIT
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ReportedMetadata:
|
|
27
|
+
"""The experiment conditions as an adapter read them from the solver."""
|
|
28
|
+
|
|
29
|
+
solver: str | None = None
|
|
30
|
+
api_version: str | None = None
|
|
31
|
+
solver_version: str | None = None
|
|
32
|
+
problem: Mapping[str, float | str | bool] = field(default_factory=dict)
|
|
33
|
+
parameters: Sequence[SolverParameter] = ()
|
|
34
|
+
wall_time_includes_problem_build: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CollectedMetadata:
|
|
39
|
+
"""The tags and params recorded for the experiment conditions, under final key names."""
|
|
40
|
+
|
|
41
|
+
tags: Mapping[str, str]
|
|
42
|
+
params: Mapping[str, str]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Common rules for metric names, missing values and gaps."""
|
|
2
|
+
|
|
3
|
+
from math import isfinite
|
|
4
|
+
|
|
5
|
+
from .types import (
|
|
6
|
+
COMMON_METRICS,
|
|
7
|
+
COMMON_STATISTICS,
|
|
8
|
+
MissingReason,
|
|
9
|
+
NormalizedMetrics,
|
|
10
|
+
ReportedMetrics,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _record_reported_metric(
|
|
15
|
+
values: dict[str, float],
|
|
16
|
+
missing: dict[str, MissingReason],
|
|
17
|
+
metric_name: str,
|
|
18
|
+
reported_value: float | MissingReason,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Record a finite reported metric or its missing reason."""
|
|
21
|
+
if isinstance(reported_value, MissingReason):
|
|
22
|
+
missing[metric_name] = reported_value
|
|
23
|
+
return
|
|
24
|
+
if isfinite(reported_value):
|
|
25
|
+
values[metric_name] = reported_value
|
|
26
|
+
return
|
|
27
|
+
missing[metric_name] = MissingReason.UNDEFINED
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_metrics(
|
|
31
|
+
reported: ReportedMetrics | None,
|
|
32
|
+
*,
|
|
33
|
+
api: str,
|
|
34
|
+
has_solution: bool | None,
|
|
35
|
+
wall_time_seconds: float,
|
|
36
|
+
include_native: bool,
|
|
37
|
+
) -> NormalizedMetrics:
|
|
38
|
+
"""The recordable metrics of a solve."""
|
|
39
|
+
values: dict[str, float] = {}
|
|
40
|
+
missing: dict[str, MissingReason] = {}
|
|
41
|
+
tags: dict[str, str] = {}
|
|
42
|
+
|
|
43
|
+
_record_reported_metric(values, missing, "wall_time_seconds", wall_time_seconds)
|
|
44
|
+
|
|
45
|
+
if reported is None:
|
|
46
|
+
tags["optimization.has_objective"] = "unknown"
|
|
47
|
+
for metric_name in COMMON_METRICS:
|
|
48
|
+
if metric_name != "wall_time_seconds":
|
|
49
|
+
missing[metric_name] = MissingReason.FAILED
|
|
50
|
+
else:
|
|
51
|
+
if reported.has_objective is None:
|
|
52
|
+
tags["optimization.has_objective"] = "unknown"
|
|
53
|
+
else:
|
|
54
|
+
tags["optimization.has_objective"] = str(reported.has_objective).lower()
|
|
55
|
+
if reported.sense is not None:
|
|
56
|
+
tags["optimization.objective_sense"] = reported.sense.value
|
|
57
|
+
|
|
58
|
+
if reported.has_objective is False:
|
|
59
|
+
missing["objective_value"] = MissingReason.UNDEFINED
|
|
60
|
+
missing["dual_bound"] = MissingReason.UNDEFINED
|
|
61
|
+
elif has_solution is False:
|
|
62
|
+
missing["objective_value"] = MissingReason.NO_SOLUTION
|
|
63
|
+
_record_reported_metric(values, missing, "dual_bound", reported.dual_bound)
|
|
64
|
+
else:
|
|
65
|
+
_record_reported_metric(
|
|
66
|
+
values, missing, "objective_value", reported.objective_value
|
|
67
|
+
)
|
|
68
|
+
_record_reported_metric(values, missing, "dual_bound", reported.dual_bound)
|
|
69
|
+
|
|
70
|
+
_record_reported_metric(
|
|
71
|
+
values, missing, "solver_time_seconds", reported.solver_time_seconds
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for metric_name, reported_value in reported.statistics.items():
|
|
75
|
+
if metric_name in COMMON_STATISTICS:
|
|
76
|
+
_record_reported_metric(values, missing, metric_name, reported_value)
|
|
77
|
+
|
|
78
|
+
if reported.has_objective is False:
|
|
79
|
+
missing["absolute_gap"] = MissingReason.UNDEFINED
|
|
80
|
+
missing["relative_gap"] = MissingReason.UNDEFINED
|
|
81
|
+
elif has_solution is False:
|
|
82
|
+
missing["absolute_gap"] = MissingReason.NO_SOLUTION
|
|
83
|
+
missing["relative_gap"] = MissingReason.NO_SOLUTION
|
|
84
|
+
elif "objective_value" in missing:
|
|
85
|
+
missing["absolute_gap"] = missing["objective_value"]
|
|
86
|
+
missing["relative_gap"] = missing["objective_value"]
|
|
87
|
+
elif "dual_bound" in missing:
|
|
88
|
+
missing["absolute_gap"] = missing["dual_bound"]
|
|
89
|
+
missing["relative_gap"] = missing["dual_bound"]
|
|
90
|
+
else:
|
|
91
|
+
absolute_gap: float = abs(values["objective_value"] - values["dual_bound"])
|
|
92
|
+
if isfinite(absolute_gap):
|
|
93
|
+
values["absolute_gap"] = absolute_gap
|
|
94
|
+
if values["objective_value"] == 0.0:
|
|
95
|
+
if absolute_gap == 0.0:
|
|
96
|
+
values["relative_gap"] = 0.0
|
|
97
|
+
else:
|
|
98
|
+
missing["relative_gap"] = MissingReason.UNDEFINED
|
|
99
|
+
else:
|
|
100
|
+
relative_gap: float = absolute_gap / abs(values["objective_value"])
|
|
101
|
+
if isfinite(relative_gap):
|
|
102
|
+
values["relative_gap"] = relative_gap
|
|
103
|
+
else:
|
|
104
|
+
missing["relative_gap"] = MissingReason.UNDEFINED
|
|
105
|
+
else:
|
|
106
|
+
missing["absolute_gap"] = MissingReason.UNDEFINED
|
|
107
|
+
missing["relative_gap"] = MissingReason.UNDEFINED
|
|
108
|
+
|
|
109
|
+
for metric_name in COMMON_METRICS:
|
|
110
|
+
if metric_name not in values and metric_name not in missing:
|
|
111
|
+
missing[metric_name] = MissingReason.UNSUPPORTED
|
|
112
|
+
if metric_name in missing:
|
|
113
|
+
tags[f"optimization.missing.{metric_name}"] = missing[metric_name].value
|
|
114
|
+
|
|
115
|
+
if reported is not None and include_native:
|
|
116
|
+
for native_name, native_value in reported.native.items():
|
|
117
|
+
api_metric_name: str = f"{api}.{native_name}"
|
|
118
|
+
if isfinite(native_value):
|
|
119
|
+
values[api_metric_name] = native_value
|
|
120
|
+
|
|
121
|
+
return NormalizedMetrics(values=values, tags=tags)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Objective, bound, time and search statistics of a solve."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MissingReason(StrEnum):
|
|
9
|
+
"""Why a value is absent."""
|
|
10
|
+
|
|
11
|
+
UNSUPPORTED = "unsupported"
|
|
12
|
+
UNDEFINED = "undefined"
|
|
13
|
+
NO_SOLUTION = "no_solution"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ObjectiveSense(StrEnum):
|
|
18
|
+
"""The optimization direction."""
|
|
19
|
+
|
|
20
|
+
MINIMIZE = "minimize"
|
|
21
|
+
MAXIMIZE = "maximize"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
COMMON_STATISTICS = ("node_count", "simplex_iterations", "barrier_iterations")
|
|
25
|
+
COMMON_METRICS = (
|
|
26
|
+
"objective_value",
|
|
27
|
+
"dual_bound",
|
|
28
|
+
"absolute_gap",
|
|
29
|
+
"relative_gap",
|
|
30
|
+
"wall_time_seconds",
|
|
31
|
+
"solver_time_seconds",
|
|
32
|
+
*COMMON_STATISTICS,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ReportedMetrics:
|
|
38
|
+
"""The numbers of a solve as an adapter read them from the solver."""
|
|
39
|
+
|
|
40
|
+
has_objective: bool | None = None
|
|
41
|
+
sense: ObjectiveSense | None = None
|
|
42
|
+
objective_value: float | MissingReason = MissingReason.UNSUPPORTED
|
|
43
|
+
dual_bound: float | MissingReason = MissingReason.UNSUPPORTED
|
|
44
|
+
solver_time_seconds: float | MissingReason = MissingReason.UNSUPPORTED
|
|
45
|
+
statistics: Mapping[str, float | MissingReason] = field(default_factory=dict)
|
|
46
|
+
native: Mapping[str, float] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class NormalizedMetrics:
|
|
51
|
+
"""The metrics and metric-related tags recorded for a solve, under final key names."""
|
|
52
|
+
|
|
53
|
+
values: Mapping[str, float]
|
|
54
|
+
tags: Mapping[str, str]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Common rules for termination and solution availability."""
|
|
2
|
+
|
|
3
|
+
from mlflow_optimization.common.diagnostics import warn
|
|
4
|
+
|
|
5
|
+
from .types import ReportedOutcome, SolveOutcome, Termination
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def classify_outcome(
|
|
9
|
+
reported: ReportedOutcome | None,
|
|
10
|
+
error: BaseException | None,
|
|
11
|
+
*,
|
|
12
|
+
has_objective: bool | None = None,
|
|
13
|
+
) -> SolveOutcome:
|
|
14
|
+
"""The validated outcome of a solve."""
|
|
15
|
+
if reported is None:
|
|
16
|
+
if isinstance(error, KeyboardInterrupt):
|
|
17
|
+
return SolveOutcome("unknown", Termination.INTERRUPTED, None)
|
|
18
|
+
if error is not None:
|
|
19
|
+
return SolveOutcome("unknown", Termination.ERROR, None)
|
|
20
|
+
return SolveOutcome("unknown", Termination.UNKNOWN, None)
|
|
21
|
+
|
|
22
|
+
termination: Termination = reported.termination
|
|
23
|
+
if isinstance(error, KeyboardInterrupt):
|
|
24
|
+
termination = Termination.INTERRUPTED
|
|
25
|
+
elif error is not None:
|
|
26
|
+
termination = Termination.ERROR
|
|
27
|
+
else:
|
|
28
|
+
if termination is Termination.OPTIMAL and has_objective is False:
|
|
29
|
+
termination = Termination.FEASIBLE
|
|
30
|
+
if termination is Termination.FEASIBLE and reported.has_solution is False:
|
|
31
|
+
warn("Reported feasible termination conflicts with has_solution=False.")
|
|
32
|
+
termination = Termination.UNKNOWN
|
|
33
|
+
elif termination is Termination.OPTIMAL and reported.has_solution is False:
|
|
34
|
+
warn("Reported optimal termination conflicts with has_solution=False.")
|
|
35
|
+
elif termination is Termination.INFEASIBLE and reported.has_solution is True:
|
|
36
|
+
warn("Reported infeasible termination conflicts with has_solution=True.")
|
|
37
|
+
|
|
38
|
+
return SolveOutcome(reported.raw_status, termination, reported.has_solution)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def outcome_tags(outcome: SolveOutcome) -> dict[str, str]:
|
|
42
|
+
"""The tags that describe an outcome."""
|
|
43
|
+
has_solution: str = "unknown"
|
|
44
|
+
if outcome.has_solution is True:
|
|
45
|
+
has_solution = "true"
|
|
46
|
+
elif outcome.has_solution is False:
|
|
47
|
+
has_solution = "false"
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"optimization.termination": outcome.termination.value,
|
|
51
|
+
"optimization.raw_status": outcome.raw_status,
|
|
52
|
+
"optimization.has_solution": has_solution,
|
|
53
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""How a solve ended."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Termination(StrEnum):
|
|
8
|
+
"""The solver-independent reason a solve ended."""
|
|
9
|
+
|
|
10
|
+
OPTIMAL = "optimal"
|
|
11
|
+
FEASIBLE = "feasible"
|
|
12
|
+
INFEASIBLE = "infeasible"
|
|
13
|
+
UNBOUNDED = "unbounded"
|
|
14
|
+
INFEASIBLE_OR_UNBOUNDED = "infeasible_or_unbounded"
|
|
15
|
+
TIME_LIMIT = "time_limit"
|
|
16
|
+
ITERATION_LIMIT = "iteration_limit"
|
|
17
|
+
NODE_LIMIT = "node_limit"
|
|
18
|
+
SOLUTION_LIMIT = "solution_limit"
|
|
19
|
+
OTHER_LIMIT = "other_limit"
|
|
20
|
+
INTERRUPTED = "interrupted"
|
|
21
|
+
ERROR = "error"
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ReportedOutcome:
|
|
27
|
+
"""The end state of a solve as an adapter read it from the solver."""
|
|
28
|
+
|
|
29
|
+
raw_status: str
|
|
30
|
+
termination: Termination
|
|
31
|
+
has_solution: bool | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SolveOutcome:
|
|
36
|
+
"""The validated end state of a solve."""
|
|
37
|
+
|
|
38
|
+
raw_status: str
|
|
39
|
+
termination: Termination
|
|
40
|
+
has_solution: bool | None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Contract of the run storage."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from .types import RunReference, SolveRecord
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RunStoreError(Exception):
|
|
10
|
+
"""An error raised when run storage fails."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunStore(Protocol):
|
|
14
|
+
"""The storage of solve runs in an experiment tracker."""
|
|
15
|
+
|
|
16
|
+
def active_run(self) -> RunReference | None:
|
|
17
|
+
"""The user's currently active run."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def save(self, record: SolveRecord, parent: RunReference | None) -> RunReference:
|
|
21
|
+
"""Create, fill and terminate the run of one finished solve."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def log_metric(self, run: RunReference, key: str, value: float) -> None:
|
|
25
|
+
"""Add one metric to an existing run."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def set_tags(self, run: RunReference, tags: Mapping[str, str]) -> None:
|
|
29
|
+
"""Add tags to an existing run."""
|
|
30
|
+
...
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Recording of completed solves and later additions to them."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mlflow_optimization.common.diagnostics import warn
|
|
8
|
+
from mlflow_optimization.features.annotations.annotate import (
|
|
9
|
+
annotation_tags,
|
|
10
|
+
current_annotation,
|
|
11
|
+
user_metric,
|
|
12
|
+
user_tags,
|
|
13
|
+
)
|
|
14
|
+
from mlflow_optimization.features.annotations.types import Annotation, UserMetric
|
|
15
|
+
from mlflow_optimization.features.artifacts.policy import artifact_refusal
|
|
16
|
+
from mlflow_optimization.features.artifacts.ports import (
|
|
17
|
+
ArtifactStore,
|
|
18
|
+
ArtifactStoreError,
|
|
19
|
+
)
|
|
20
|
+
from mlflow_optimization.features.autolog.types import CompletedSolve, SolveObservation
|
|
21
|
+
from mlflow_optimization.features.metadata.collect import collect_metadata
|
|
22
|
+
from mlflow_optimization.features.metadata.types import CollectedMetadata
|
|
23
|
+
from mlflow_optimization.features.metrics.normalize import normalize_metrics
|
|
24
|
+
from mlflow_optimization.features.metrics.types import NormalizedMetrics
|
|
25
|
+
from mlflow_optimization.features.outcomes.classify import (
|
|
26
|
+
classify_outcome,
|
|
27
|
+
outcome_tags,
|
|
28
|
+
)
|
|
29
|
+
from mlflow_optimization.features.outcomes.types import SolveOutcome
|
|
30
|
+
|
|
31
|
+
from .ports import RunStore, RunStoreError
|
|
32
|
+
from .types import RecordOptions, RunReference, RunStatus, SolveRecord
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class _RecordingContext:
|
|
37
|
+
"""The state captured at solve start."""
|
|
38
|
+
|
|
39
|
+
parent: RunReference | None
|
|
40
|
+
annotation: Annotation
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SolveRun:
|
|
44
|
+
"""The handle of one recorded solve."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
run: RunReference,
|
|
49
|
+
runs: RunStore,
|
|
50
|
+
artifacts: ArtifactStore,
|
|
51
|
+
options: RecordOptions,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize a handle for a recorded solve.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
run : RunReference
|
|
58
|
+
Reference to the recorded solve's run.
|
|
59
|
+
runs : RunStore
|
|
60
|
+
Store used to add metrics and tags to the run.
|
|
61
|
+
artifacts : ArtifactStore
|
|
62
|
+
Store used to attach files to the run.
|
|
63
|
+
options : RecordOptions
|
|
64
|
+
Options controlling recorded solver data and artifact logging.
|
|
65
|
+
"""
|
|
66
|
+
self.run_id: str = run.run_id
|
|
67
|
+
self._run: RunReference = run
|
|
68
|
+
self._runs: RunStore = runs
|
|
69
|
+
self._artifacts: ArtifactStore = artifacts
|
|
70
|
+
self._options: RecordOptions = options
|
|
71
|
+
|
|
72
|
+
def log_metric(
|
|
73
|
+
self,
|
|
74
|
+
name: str,
|
|
75
|
+
value: float,
|
|
76
|
+
*,
|
|
77
|
+
unit: str | None = None,
|
|
78
|
+
description: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Add a user metric to this solve.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
name : str
|
|
85
|
+
User metric name; its recorded key is prefixed with ``user.``.
|
|
86
|
+
value : float
|
|
87
|
+
Metric value.
|
|
88
|
+
unit : str, optional
|
|
89
|
+
Unit tag stored as ``user.{name}.unit``.
|
|
90
|
+
description : str, optional
|
|
91
|
+
Description tag stored as ``user.{name}.description``.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
entry: UserMetric | None = user_metric(
|
|
95
|
+
name, value, unit=unit, description=description
|
|
96
|
+
)
|
|
97
|
+
if entry is not None:
|
|
98
|
+
self._runs.log_metric(self._run, entry.metric_key, entry.value)
|
|
99
|
+
if entry.tags:
|
|
100
|
+
self._runs.set_tags(self._run, entry.tags)
|
|
101
|
+
except RunStoreError as error:
|
|
102
|
+
warn(f"Could not log metric: {error}")
|
|
103
|
+
|
|
104
|
+
def set_tags(self, tags: Mapping[str, str]) -> None:
|
|
105
|
+
"""Add user tags to this solve.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
tags : Mapping[str, str]
|
|
110
|
+
Tags to add; keys beginning with ``optimization.`` or ``mlflow.``
|
|
111
|
+
are ignored.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
accepted: dict[str, str] = user_tags(tags)
|
|
115
|
+
if accepted:
|
|
116
|
+
self._runs.set_tags(self._run, accepted)
|
|
117
|
+
except RunStoreError as error:
|
|
118
|
+
warn(f"Could not set tags: {error}")
|
|
119
|
+
|
|
120
|
+
def log_artifact(self, local_path: str, artifact_path: str | None = None) -> None:
|
|
121
|
+
"""Attach a local file to this solve.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
local_path : str
|
|
126
|
+
Path to an existing local file.
|
|
127
|
+
artifact_path : str, optional
|
|
128
|
+
Destination directory in run artifacts; if omitted, the file goes
|
|
129
|
+
to the artifact root.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
refusal: str | None = artifact_refusal(
|
|
133
|
+
local_path, enabled=self._options.log_artifacts
|
|
134
|
+
)
|
|
135
|
+
if refusal is not None:
|
|
136
|
+
warn(refusal)
|
|
137
|
+
return
|
|
138
|
+
self._artifacts.log_artifact(self.run_id, local_path, artifact_path)
|
|
139
|
+
except ArtifactStoreError as error:
|
|
140
|
+
warn(f"Could not log artifact: {error}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_last_solve: ContextVar[SolveRun | None] = ContextVar("last_solve", default=None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RecordSolve:
|
|
147
|
+
"""The use case that stores each completed solve as one run."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self, runs: RunStore, artifacts: ArtifactStore, options: RecordOptions
|
|
151
|
+
) -> None:
|
|
152
|
+
self._runs: RunStore = runs
|
|
153
|
+
self._artifacts: ArtifactStore = artifacts
|
|
154
|
+
self.options: RecordOptions = options
|
|
155
|
+
|
|
156
|
+
def begin(self) -> object:
|
|
157
|
+
"""The parent run and annotations in effect at solve start."""
|
|
158
|
+
parent: RunReference | None
|
|
159
|
+
try:
|
|
160
|
+
parent = self._runs.active_run()
|
|
161
|
+
except RunStoreError as error:
|
|
162
|
+
warn(f"Could not get active run: {error}")
|
|
163
|
+
parent = None
|
|
164
|
+
return _RecordingContext(parent, current_annotation())
|
|
165
|
+
|
|
166
|
+
def record(self, solve: CompletedSolve, context: object) -> None:
|
|
167
|
+
"""Store one completed solve."""
|
|
168
|
+
if not isinstance(context, _RecordingContext):
|
|
169
|
+
raise TypeError("Invalid recording context")
|
|
170
|
+
observation: SolveObservation | None = solve.observation
|
|
171
|
+
has_objective: bool | None = (
|
|
172
|
+
observation.metrics.has_objective if observation is not None else None
|
|
173
|
+
)
|
|
174
|
+
outcome: SolveOutcome = classify_outcome(
|
|
175
|
+
observation.outcome if observation else None,
|
|
176
|
+
solve.error,
|
|
177
|
+
has_objective=has_objective,
|
|
178
|
+
)
|
|
179
|
+
metrics: NormalizedMetrics = normalize_metrics(
|
|
180
|
+
observation.metrics if observation else None,
|
|
181
|
+
api=solve.api,
|
|
182
|
+
has_solution=outcome.has_solution,
|
|
183
|
+
wall_time_seconds=solve.wall_time_seconds,
|
|
184
|
+
include_native=self.options.log_solver_metrics,
|
|
185
|
+
)
|
|
186
|
+
metadata: CollectedMetadata = collect_metadata(
|
|
187
|
+
solve.api,
|
|
188
|
+
observation.metadata if observation else None,
|
|
189
|
+
include_parameters=self.options.log_solver_parameters,
|
|
190
|
+
)
|
|
191
|
+
tags: dict[str, str] = dict(annotation_tags(context.annotation))
|
|
192
|
+
tags.update(outcome_tags(outcome))
|
|
193
|
+
tags.update(metrics.tags)
|
|
194
|
+
tags.update(metadata.tags)
|
|
195
|
+
tags["optimization.solve_id"] = solve.solve_id
|
|
196
|
+
tags["optimization.solve_index"] = str(solve.solve_index)
|
|
197
|
+
if solve.error is not None:
|
|
198
|
+
tags["optimization.error"] = type(solve.error).__name__
|
|
199
|
+
status: RunStatus = RunStatus.FINISHED
|
|
200
|
+
if isinstance(solve.error, KeyboardInterrupt):
|
|
201
|
+
status = RunStatus.KILLED
|
|
202
|
+
elif isinstance(solve.error, Exception):
|
|
203
|
+
status = RunStatus.FAILED
|
|
204
|
+
solver: str = metadata.tags.get("optimization.solver") or "unknown"
|
|
205
|
+
record: SolveRecord = SolveRecord(
|
|
206
|
+
name=f"{solve.api}/{solver} #{solve.solve_index}",
|
|
207
|
+
status=status,
|
|
208
|
+
start_time_ms=solve.start_time_ms,
|
|
209
|
+
end_time_ms=solve.end_time_ms,
|
|
210
|
+
tags=tags,
|
|
211
|
+
metrics=metrics.values,
|
|
212
|
+
params=metadata.params,
|
|
213
|
+
)
|
|
214
|
+
run: RunReference
|
|
215
|
+
try:
|
|
216
|
+
run = self._runs.save(record, context.parent)
|
|
217
|
+
except RunStoreError as error:
|
|
218
|
+
warn(f"Could not record solve: {error}")
|
|
219
|
+
return
|
|
220
|
+
_last_solve.set(SolveRun(run, self._runs, self._artifacts, self.options))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def last_solve() -> SolveRun | None:
|
|
224
|
+
"""The most recent solve recorded in the current thread or async context.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
SolveRun or None
|
|
229
|
+
None until a solve is recorded in the current context.
|
|
230
|
+
"""
|
|
231
|
+
return _last_solve.get()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The stored form of a solve."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class RunReference:
|
|
10
|
+
"""The identity of a run in the experiment tracker."""
|
|
11
|
+
|
|
12
|
+
run_id: str
|
|
13
|
+
experiment_id: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RunStatus(StrEnum):
|
|
17
|
+
"""The lifecycle state a solve's run ends in."""
|
|
18
|
+
|
|
19
|
+
FINISHED = "FINISHED"
|
|
20
|
+
FAILED = "FAILED"
|
|
21
|
+
KILLED = "KILLED"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SolveRecord:
|
|
26
|
+
"""Everything stored for one solve, under final key names."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
status: RunStatus
|
|
30
|
+
start_time_ms: int
|
|
31
|
+
end_time_ms: int
|
|
32
|
+
tags: Mapping[str, str]
|
|
33
|
+
metrics: Mapping[str, float]
|
|
34
|
+
params: Mapping[str, str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RecordOptions:
|
|
39
|
+
"""Which optional parts of a solve are recorded."""
|
|
40
|
+
|
|
41
|
+
log_solver_parameters: bool = True
|
|
42
|
+
log_solver_metrics: bool = True
|
|
43
|
+
log_artifacts: bool = True
|