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,50 @@
|
|
|
1
|
+
"""Automatic MLflow logging of mathematical optimization solves."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from mlflow_optimization import _bootstrap
|
|
6
|
+
from mlflow_optimization.common.diagnostics import MlflowOptimizationWarning
|
|
7
|
+
from mlflow_optimization.features.annotations.annotate import context
|
|
8
|
+
from mlflow_optimization.features.runs.record import SolveRun, last_solve
|
|
9
|
+
|
|
10
|
+
__all__ = ["MlflowOptimizationWarning", "SolveRun", "autolog", "context", "last_solve"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def autolog(
|
|
14
|
+
disable: bool = False,
|
|
15
|
+
*,
|
|
16
|
+
libraries: Iterable[str] | None = None,
|
|
17
|
+
log_solver_parameters: bool = True,
|
|
18
|
+
log_solver_metrics: bool = True,
|
|
19
|
+
log_artifacts: bool = True,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Configure automatic logging of optimization solves.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
disable : bool, default False
|
|
26
|
+
Disable automatic logging; other settings are ignored when True.
|
|
27
|
+
libraries : Iterable[str] or None, optional
|
|
28
|
+
Supported names: gurobipy, pyscipopt, pyomo, mathopt, cp_sat, routing.
|
|
29
|
+
None selects all available integrations; an empty iterable selects none.
|
|
30
|
+
log_solver_parameters : bool, default True
|
|
31
|
+
Whether solver parameters are logged.
|
|
32
|
+
log_solver_metrics : bool, default True
|
|
33
|
+
Whether native solver metrics are logged.
|
|
34
|
+
log_artifacts : bool, default True
|
|
35
|
+
Whether files attached through ``SolveRun.log_artifact`` are logged.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
ImportError
|
|
40
|
+
An explicitly selected integration cannot be imported.
|
|
41
|
+
ValueError
|
|
42
|
+
An unsupported library name is selected while logging is enabled.
|
|
43
|
+
"""
|
|
44
|
+
_bootstrap.configure(
|
|
45
|
+
disable=disable,
|
|
46
|
+
libraries=libraries,
|
|
47
|
+
log_solver_parameters=log_solver_parameters,
|
|
48
|
+
log_solver_metrics=log_solver_metrics,
|
|
49
|
+
log_artifacts=log_artifacts,
|
|
50
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Composition root: wires feature ports to concrete adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from mlflow_optimization.features.autolog.ports import SolverIntegration
|
|
12
|
+
from mlflow_optimization.features.autolog.service import AutologManager
|
|
13
|
+
from mlflow_optimization.features.runs.record import RecordSolve
|
|
14
|
+
from mlflow_optimization.features.runs.types import RecordOptions
|
|
15
|
+
from mlflow_optimization.integrations.mlflow.artifacts import MlflowArtifactStore
|
|
16
|
+
from mlflow_optimization.integrations.mlflow.runs import MlflowRunStore
|
|
17
|
+
|
|
18
|
+
INTEGRATION_MODULES: dict[str, tuple[str, str]] = {
|
|
19
|
+
"gurobipy": ("mlflow_optimization.integrations.gurobipy.adapter", "GurobiAdapter"),
|
|
20
|
+
"pyscipopt": (
|
|
21
|
+
"mlflow_optimization.integrations.pyscipopt.adapter",
|
|
22
|
+
"PyScipOptAdapter",
|
|
23
|
+
),
|
|
24
|
+
"pyomo": ("mlflow_optimization.integrations.pyomo.adapter", "PyomoAdapter"),
|
|
25
|
+
"mathopt": ("mlflow_optimization.integrations.mathopt.adapter", "MathOptAdapter"),
|
|
26
|
+
"cp_sat": ("mlflow_optimization.integrations.cp_sat.adapter", "CpSatAdapter"),
|
|
27
|
+
"routing": ("mlflow_optimization.integrations.routing.adapter", "RoutingAdapter"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_record_solve: RecordSolve | None = None
|
|
31
|
+
_autolog_manager: AutologManager | None = None
|
|
32
|
+
_integration_cache: dict[type[SolverIntegration], SolverIntegration] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def configure(
|
|
36
|
+
*,
|
|
37
|
+
disable: bool,
|
|
38
|
+
libraries: Iterable[str] | None,
|
|
39
|
+
log_solver_parameters: bool,
|
|
40
|
+
log_solver_metrics: bool,
|
|
41
|
+
log_artifacts: bool,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Apply an autolog configuration to the process-wide wiring."""
|
|
44
|
+
global _autolog_manager
|
|
45
|
+
global _record_solve
|
|
46
|
+
|
|
47
|
+
integrations: list[SolverIntegration] = []
|
|
48
|
+
if not disable:
|
|
49
|
+
integrations = _resolve_integrations(libraries)
|
|
50
|
+
|
|
51
|
+
if _autolog_manager is None:
|
|
52
|
+
from mlflow_optimization.features.autolog.service import AutologManager
|
|
53
|
+
from mlflow_optimization.features.runs.record import RecordSolve
|
|
54
|
+
from mlflow_optimization.features.runs.types import RecordOptions
|
|
55
|
+
from mlflow_optimization.integrations.mlflow.artifacts import (
|
|
56
|
+
MlflowArtifactStore,
|
|
57
|
+
)
|
|
58
|
+
from mlflow_optimization.integrations.mlflow.runs import MlflowRunStore
|
|
59
|
+
|
|
60
|
+
run_store: MlflowRunStore = MlflowRunStore()
|
|
61
|
+
artifact_store: MlflowArtifactStore = MlflowArtifactStore()
|
|
62
|
+
record_options: RecordOptions = RecordOptions()
|
|
63
|
+
if not disable:
|
|
64
|
+
record_options = RecordOptions(
|
|
65
|
+
log_solver_parameters=log_solver_parameters,
|
|
66
|
+
log_solver_metrics=log_solver_metrics,
|
|
67
|
+
log_artifacts=log_artifacts,
|
|
68
|
+
)
|
|
69
|
+
record_solve: RecordSolve = RecordSolve(
|
|
70
|
+
run_store, artifact_store, record_options
|
|
71
|
+
)
|
|
72
|
+
autolog_manager: AutologManager = AutologManager(record_solve)
|
|
73
|
+
|
|
74
|
+
_record_solve = record_solve
|
|
75
|
+
_autolog_manager = autolog_manager
|
|
76
|
+
else:
|
|
77
|
+
assert _record_solve is not None
|
|
78
|
+
if not disable:
|
|
79
|
+
_record_solve.options.log_solver_parameters = log_solver_parameters
|
|
80
|
+
_record_solve.options.log_solver_metrics = log_solver_metrics
|
|
81
|
+
_record_solve.options.log_artifacts = log_artifacts
|
|
82
|
+
|
|
83
|
+
assert _autolog_manager is not None
|
|
84
|
+
if disable:
|
|
85
|
+
_autolog_manager.disable()
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
_autolog_manager.enable(integrations)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_integrations(libraries: Iterable[str] | None) -> list[SolverIntegration]:
|
|
92
|
+
"""The selected solver integrations."""
|
|
93
|
+
library_modules: dict[str, tuple[str, str]] = dict(INTEGRATION_MODULES)
|
|
94
|
+
selected_libraries: tuple[str, ...] = (
|
|
95
|
+
tuple(library_modules) if libraries is None else tuple(dict.fromkeys(libraries))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for library_name in selected_libraries:
|
|
99
|
+
if library_name not in library_modules:
|
|
100
|
+
valid_library_names: str = ", ".join(library_modules)
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Unknown library {library_name!r}. Valid names: {valid_library_names}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
integrations: list[SolverIntegration] = []
|
|
106
|
+
for library_name in selected_libraries:
|
|
107
|
+
module_name: str
|
|
108
|
+
class_name: str
|
|
109
|
+
module_name, class_name = library_modules[library_name]
|
|
110
|
+
try:
|
|
111
|
+
integration_module: ModuleType = import_module(module_name)
|
|
112
|
+
integration_class: type[SolverIntegration] = getattr(
|
|
113
|
+
integration_module, class_name
|
|
114
|
+
)
|
|
115
|
+
cached_integration: SolverIntegration | None = _integration_cache.get(
|
|
116
|
+
integration_class
|
|
117
|
+
)
|
|
118
|
+
if cached_integration is not None:
|
|
119
|
+
integration: SolverIntegration = cached_integration
|
|
120
|
+
else:
|
|
121
|
+
integration = integration_class()
|
|
122
|
+
_integration_cache[integration_class] = integration
|
|
123
|
+
except ImportError:
|
|
124
|
+
if libraries is not None:
|
|
125
|
+
raise
|
|
126
|
+
continue
|
|
127
|
+
integrations.append(integration)
|
|
128
|
+
|
|
129
|
+
return integrations
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Notification channel shared by every feature and integration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MlflowOptimizationWarning(UserWarning):
|
|
8
|
+
"""The category of every warning emitted by this library."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def warn(message: str, *, stacklevel: int = 2) -> None:
|
|
12
|
+
"""Emit an ``MlflowOptimizationWarning``."""
|
|
13
|
+
try:
|
|
14
|
+
warnings.warn(message, MlflowOptimizationWarning, stacklevel=stacklevel + 1)
|
|
15
|
+
except BaseException: # noqa: BLE001
|
|
16
|
+
try:
|
|
17
|
+
logging.getLogger("mlflow_optimization").warning(message)
|
|
18
|
+
except BaseException: # noqa: BLE001,S110
|
|
19
|
+
pass
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Reversible attribute replacement."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from inspect import signature
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def receiver_of(
|
|
11
|
+
original: Callable[..., Any], args: tuple[Any, ...], kwargs: Mapping[str, Any]
|
|
12
|
+
) -> Any:
|
|
13
|
+
"""The receiver of a method call."""
|
|
14
|
+
receiver_name: str | None = next(iter(signature(original).parameters), None)
|
|
15
|
+
if receiver_name is None:
|
|
16
|
+
return None
|
|
17
|
+
if args:
|
|
18
|
+
return args[0]
|
|
19
|
+
return kwargs.get(receiver_name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rebind_module_names(
|
|
23
|
+
original: Any,
|
|
24
|
+
replacement: Any,
|
|
25
|
+
) -> list[tuple[ModuleType, str]]:
|
|
26
|
+
"""The module-level names rebound from an original object to a replacement."""
|
|
27
|
+
rebound_names: list[tuple[ModuleType, str]] = []
|
|
28
|
+
modules: tuple[Any, ...] = tuple(sys.modules.values())
|
|
29
|
+
for module_value in modules:
|
|
30
|
+
if not isinstance(module_value, ModuleType):
|
|
31
|
+
continue
|
|
32
|
+
module: ModuleType = module_value
|
|
33
|
+
module_values: dict[str, Any] = vars(module)
|
|
34
|
+
module_bindings: tuple[tuple[str, Any], ...] = tuple(module_values.items())
|
|
35
|
+
for name, value in module_bindings:
|
|
36
|
+
if value is not original:
|
|
37
|
+
continue
|
|
38
|
+
module_values[name] = replacement
|
|
39
|
+
rebound_names.append((module, name))
|
|
40
|
+
return rebound_names
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def restore_module_names(
|
|
44
|
+
rebound_names: list[tuple[ModuleType, str]],
|
|
45
|
+
original: Any,
|
|
46
|
+
replacement: Any,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""A conditional restoration of module-level names."""
|
|
49
|
+
for module, name in reversed(rebound_names):
|
|
50
|
+
module_values: dict[str, Any] = vars(module)
|
|
51
|
+
if name not in module_values or module_values[name] is not replacement:
|
|
52
|
+
continue
|
|
53
|
+
module_values[name] = original
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Patch:
|
|
57
|
+
"""A reversible replacement of one attribute on a class or module."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, owner: Any, name: str) -> None:
|
|
60
|
+
self.owner: Any = owner
|
|
61
|
+
self.name: str = name
|
|
62
|
+
self._installed: bool = False
|
|
63
|
+
|
|
64
|
+
def install(self, factory: Callable[[Any], Any]) -> None:
|
|
65
|
+
"""Replace the attribute with ``factory(original)``; no-op when installed."""
|
|
66
|
+
if self._installed:
|
|
67
|
+
return
|
|
68
|
+
original: Any = getattr(self.owner, self.name)
|
|
69
|
+
self._own: bool = self.name in vars(self.owner)
|
|
70
|
+
self._descriptor: Any = vars(self.owner).get(self.name)
|
|
71
|
+
setattr(self.owner, self.name, factory(original))
|
|
72
|
+
self._installed = True
|
|
73
|
+
|
|
74
|
+
def uninstall(self) -> None:
|
|
75
|
+
"""Restore the exact original attribute; no-op when not installed."""
|
|
76
|
+
if not self._installed:
|
|
77
|
+
return
|
|
78
|
+
if self._own:
|
|
79
|
+
setattr(self.owner, self.name, self._descriptor)
|
|
80
|
+
else:
|
|
81
|
+
delattr(self.owner, self.name)
|
|
82
|
+
self._installed = False
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Scoping and naming rules for user annotations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from math import isfinite
|
|
7
|
+
|
|
8
|
+
from mlflow_optimization.common.diagnostics import warn
|
|
9
|
+
|
|
10
|
+
from .types import Annotation, UserMetric
|
|
11
|
+
|
|
12
|
+
_annotation_context: ContextVar[Annotation] = ContextVar(
|
|
13
|
+
"annotation_context",
|
|
14
|
+
default=Annotation(), # noqa: B039 -- context() copies tags before merging.
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def context(
|
|
20
|
+
*,
|
|
21
|
+
instance: str | None = None,
|
|
22
|
+
description: str | None = None,
|
|
23
|
+
tags: Mapping[str, str] | None = None,
|
|
24
|
+
) -> Iterator[None]:
|
|
25
|
+
"""A scope whose annotations apply to every solve started inside it.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
instance : str, optional
|
|
30
|
+
Instance name; inherited from the enclosing scope when omitted.
|
|
31
|
+
description : str, optional
|
|
32
|
+
Description; inherited from the enclosing scope when omitted.
|
|
33
|
+
tags : Mapping[str, str], optional
|
|
34
|
+
User tags merge with enclosing tags; inner values replace outer values for
|
|
35
|
+
matching keys. Keys prefixed ``optimization.`` or ``mlflow.`` are ignored.
|
|
36
|
+
"""
|
|
37
|
+
enclosing_annotation: Annotation = current_annotation()
|
|
38
|
+
merged_tags: dict[str, str] = dict(enclosing_annotation.tags)
|
|
39
|
+
if tags is not None:
|
|
40
|
+
merged_tags.update(user_tags(tags))
|
|
41
|
+
merged_annotation: Annotation = Annotation(
|
|
42
|
+
instance=instance if instance is not None else enclosing_annotation.instance,
|
|
43
|
+
description=(
|
|
44
|
+
description if description is not None else enclosing_annotation.description
|
|
45
|
+
),
|
|
46
|
+
tags=merged_tags,
|
|
47
|
+
)
|
|
48
|
+
token: Token[Annotation] = _annotation_context.set(merged_annotation)
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
_annotation_context.reset(token)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def current_annotation() -> Annotation:
|
|
56
|
+
"""The merged annotations of the enclosing ``context`` scopes."""
|
|
57
|
+
return _annotation_context.get()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def annotation_tags(annotation: Annotation) -> dict[str, str]:
|
|
61
|
+
"""The tags that record an annotation."""
|
|
62
|
+
tags: dict[str, str] = user_tags(annotation.tags)
|
|
63
|
+
if annotation.instance is not None:
|
|
64
|
+
tags["optimization.instance"] = annotation.instance
|
|
65
|
+
if annotation.description is not None:
|
|
66
|
+
tags["mlflow.note.content"] = annotation.description
|
|
67
|
+
return tags
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def user_tags(tags: Mapping[str, str]) -> dict[str, str]:
|
|
71
|
+
"""The subset of user tags that may be recorded."""
|
|
72
|
+
accepted_tags: dict[str, str] = {}
|
|
73
|
+
for key, value in tags.items():
|
|
74
|
+
if key.startswith(("optimization.", "mlflow.")):
|
|
75
|
+
warn(f"Ignoring user tag with reserved key {key!r}.")
|
|
76
|
+
else:
|
|
77
|
+
accepted_tags[key] = value
|
|
78
|
+
return accepted_tags
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def user_metric(
|
|
82
|
+
name: str, value: float, *, unit: str | None = None, description: str | None = None
|
|
83
|
+
) -> UserMetric | None:
|
|
84
|
+
"""The record of one user metric, or None when it may not be recorded."""
|
|
85
|
+
if name.startswith(("optimization.", "mlflow.")):
|
|
86
|
+
warn(f"Ignoring user metric with reserved name {name!r}.")
|
|
87
|
+
return None
|
|
88
|
+
if not isfinite(value):
|
|
89
|
+
warn(f"Ignoring user metric {name!r} because its value is not finite.")
|
|
90
|
+
return None
|
|
91
|
+
metric_key: str = f"user.{name}"
|
|
92
|
+
tags: dict[str, str] = {}
|
|
93
|
+
if unit is not None:
|
|
94
|
+
tags[f"{metric_key}.unit"] = unit
|
|
95
|
+
if description is not None:
|
|
96
|
+
tags[f"{metric_key}.description"] = description
|
|
97
|
+
return UserMetric(metric_key=metric_key, value=value, tags=tags)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""User-provided information attached to solves."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Annotation:
|
|
9
|
+
"""The user annotations in effect for a solve."""
|
|
10
|
+
|
|
11
|
+
instance: str | None = None
|
|
12
|
+
description: str | None = None
|
|
13
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class UserMetric:
|
|
18
|
+
"""One user metric under final key names."""
|
|
19
|
+
|
|
20
|
+
metric_key: str
|
|
21
|
+
value: float
|
|
22
|
+
tags: Mapping[str, str]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Rules for which explicit artifact requests are honored."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def artifact_refusal(local_path: str, *, enabled: bool) -> str | None:
|
|
7
|
+
"""The reason an explicit artifact request is refused, or None when it is honored."""
|
|
8
|
+
if not enabled:
|
|
9
|
+
return "Artifact logging is disabled."
|
|
10
|
+
try:
|
|
11
|
+
is_file: bool = Path(local_path).is_file()
|
|
12
|
+
except OSError:
|
|
13
|
+
return f"Artifact path cannot be inspected: {local_path}"
|
|
14
|
+
if not is_file:
|
|
15
|
+
return f"Artifact path is not an existing file: {local_path}"
|
|
16
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Contract of the artifact storage."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ArtifactStoreError(Exception):
|
|
7
|
+
"""An error raised when artifact storage fails."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ArtifactStore(Protocol):
|
|
11
|
+
"""The storage of files attached to runs."""
|
|
12
|
+
|
|
13
|
+
def log_artifact(
|
|
14
|
+
self, run_id: str, local_path: str, artifact_path: str | None
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Attach a local file to a run."""
|
|
17
|
+
...
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Contracts between solve observation and its surroundings."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from .types import CompletedSolve, SolveObservation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ObservationError(Exception):
|
|
10
|
+
"""An error raised when a solve observation cannot be built."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SolveObserver(Protocol):
|
|
14
|
+
"""The entry through which an adapter routes a solve call."""
|
|
15
|
+
|
|
16
|
+
def observe[T](
|
|
17
|
+
self,
|
|
18
|
+
api: str,
|
|
19
|
+
call: Callable[[], T],
|
|
20
|
+
extract: Callable[[T | None, BaseException | None], SolveObservation],
|
|
21
|
+
) -> T:
|
|
22
|
+
"""The result of ``call()``, observed as one solve unless nested or disabled."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SolverIntegration(Protocol):
|
|
27
|
+
"""The hook of one Python solver API."""
|
|
28
|
+
|
|
29
|
+
api: str
|
|
30
|
+
|
|
31
|
+
def install(self, observer: SolveObserver) -> None:
|
|
32
|
+
"""Route the SDK's solve entry points through ``observer``."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def uninstall(self) -> None:
|
|
36
|
+
"""Restore the SDK's original solve entry points."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SolveSink(Protocol):
|
|
41
|
+
"""The destination of completed solves."""
|
|
42
|
+
|
|
43
|
+
def begin(self) -> object:
|
|
44
|
+
"""The recording context captured at solve start."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def record(self, solve: CompletedSolve, context: object) -> None:
|
|
48
|
+
"""Store one completed solve."""
|
|
49
|
+
...
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Activation of integrations and arbitration of observed solve calls."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
4
|
+
from contextvars import ContextVar, Token
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from itertools import count
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from time import perf_counter, time
|
|
9
|
+
from typing import cast
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from mlflow_optimization.common.diagnostics import warn
|
|
13
|
+
|
|
14
|
+
from .ports import SolverIntegration, SolveSink
|
|
15
|
+
from .types import CompletedSolve, SolveObservation
|
|
16
|
+
|
|
17
|
+
_solve_indices: Iterator[int] = count(1)
|
|
18
|
+
_solve_index_lock: Lock = Lock()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _SolveScope:
|
|
22
|
+
"""The lifetime of an observed solve call."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.active: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_observing: ContextVar[_SolveScope | None] = ContextVar(
|
|
29
|
+
"autolog_observing", default=None
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AutologManager:
|
|
34
|
+
"""The observer that turns outermost solve calls into completed solves."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, sink: SolveSink) -> None:
|
|
37
|
+
self._sink: SolveSink = sink
|
|
38
|
+
self._integrations: list[SolverIntegration] = []
|
|
39
|
+
self._enabled: bool = False
|
|
40
|
+
|
|
41
|
+
def enable(self, integrations: Sequence[SolverIntegration]) -> None:
|
|
42
|
+
"""Observe exactly the given integrations."""
|
|
43
|
+
for integration in self._integrations[:]:
|
|
44
|
+
if not any(integration is candidate for candidate in integrations):
|
|
45
|
+
integration.uninstall()
|
|
46
|
+
self._integrations.remove(integration)
|
|
47
|
+
for integration in integrations:
|
|
48
|
+
if not any(integration is installed for installed in self._integrations):
|
|
49
|
+
integration.install(self)
|
|
50
|
+
self._integrations.append(integration)
|
|
51
|
+
self._enabled = True
|
|
52
|
+
|
|
53
|
+
def disable(self) -> None:
|
|
54
|
+
"""Stop observing and uninstall every integration."""
|
|
55
|
+
self._enabled = False
|
|
56
|
+
for integration in self._integrations[:]:
|
|
57
|
+
integration.uninstall()
|
|
58
|
+
self._integrations.remove(integration)
|
|
59
|
+
|
|
60
|
+
def observe[T](
|
|
61
|
+
self,
|
|
62
|
+
api: str,
|
|
63
|
+
call: Callable[[], T],
|
|
64
|
+
extract: Callable[[T | None, BaseException | None], SolveObservation],
|
|
65
|
+
) -> T:
|
|
66
|
+
"""The result of ``call()``, observed as one solve unless nested or disabled."""
|
|
67
|
+
parent: _SolveScope | None = _observing.get()
|
|
68
|
+
if not self._enabled or (parent is not None and parent.active):
|
|
69
|
+
return call()
|
|
70
|
+
scope: _SolveScope = _SolveScope()
|
|
71
|
+
token: Token[_SolveScope | None] = _observing.set(scope)
|
|
72
|
+
try:
|
|
73
|
+
failures: list[str] = []
|
|
74
|
+
|
|
75
|
+
def guard(step: str, operation: Callable[[], object]) -> object:
|
|
76
|
+
"""The result of a guarded observation operation."""
|
|
77
|
+
try:
|
|
78
|
+
return operation()
|
|
79
|
+
# NOTE: A defect in logging must never change the result of the user's solve.
|
|
80
|
+
except Exception as exception: # noqa: BLE001
|
|
81
|
+
failures.append(
|
|
82
|
+
f"Solve {step} failed for {api}: "
|
|
83
|
+
f"{type(exception).__name__}: {exception}"
|
|
84
|
+
)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
context: object = guard("begin", self._sink.begin)
|
|
88
|
+
with _solve_index_lock:
|
|
89
|
+
solve_index: int = next(_solve_indices)
|
|
90
|
+
solve_id: str = uuid4().hex
|
|
91
|
+
start_time_ms: int = int(time() * 1000)
|
|
92
|
+
result: T | None = None
|
|
93
|
+
|
|
94
|
+
def finish(solve: CompletedSolve) -> None:
|
|
95
|
+
"""Extract and record a completed solve."""
|
|
96
|
+
observation: SolveObservation | None = None
|
|
97
|
+
try:
|
|
98
|
+
observation = extract(result, solve.error)
|
|
99
|
+
# NOTE: A defect in extraction must never change the result of the user's solve.
|
|
100
|
+
except Exception as exception: # noqa: BLE001
|
|
101
|
+
failures.append(
|
|
102
|
+
f"Solve extraction failed for {api}: "
|
|
103
|
+
f"{type(exception).__name__}: {exception}"
|
|
104
|
+
)
|
|
105
|
+
self._sink.record(replace(solve, observation=observation), context)
|
|
106
|
+
|
|
107
|
+
start: float = perf_counter()
|
|
108
|
+
try:
|
|
109
|
+
result = call()
|
|
110
|
+
except BaseException as error:
|
|
111
|
+
end: float = perf_counter()
|
|
112
|
+
scope.active = False
|
|
113
|
+
end_time_ms: int = int(time() * 1000)
|
|
114
|
+
solve: CompletedSolve = CompletedSolve(
|
|
115
|
+
api,
|
|
116
|
+
solve_id,
|
|
117
|
+
solve_index,
|
|
118
|
+
start_time_ms,
|
|
119
|
+
end_time_ms,
|
|
120
|
+
end - start,
|
|
121
|
+
None,
|
|
122
|
+
error,
|
|
123
|
+
)
|
|
124
|
+
guard("recording", lambda: finish(solve))
|
|
125
|
+
for failure in failures:
|
|
126
|
+
warn(failure)
|
|
127
|
+
raise
|
|
128
|
+
end = perf_counter()
|
|
129
|
+
scope.active = False
|
|
130
|
+
end_time_ms = int(time() * 1000)
|
|
131
|
+
solve = CompletedSolve(
|
|
132
|
+
api,
|
|
133
|
+
solve_id,
|
|
134
|
+
solve_index,
|
|
135
|
+
start_time_ms,
|
|
136
|
+
end_time_ms,
|
|
137
|
+
end - start,
|
|
138
|
+
None,
|
|
139
|
+
None,
|
|
140
|
+
)
|
|
141
|
+
guard("recording", lambda: finish(solve))
|
|
142
|
+
for failure in failures:
|
|
143
|
+
warn(failure)
|
|
144
|
+
return cast(T, result)
|
|
145
|
+
finally:
|
|
146
|
+
scope.active = False
|
|
147
|
+
_observing.reset(token)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""What is observed about a solve."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from mlflow_optimization.features.metadata.types import ReportedMetadata
|
|
6
|
+
from mlflow_optimization.features.metrics.types import ReportedMetrics
|
|
7
|
+
from mlflow_optimization.features.outcomes.types import ReportedOutcome
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SolveObservation:
|
|
12
|
+
"""The SDK-free snapshot an adapter takes of one finished solve."""
|
|
13
|
+
|
|
14
|
+
api: str
|
|
15
|
+
outcome: ReportedOutcome
|
|
16
|
+
metrics: ReportedMetrics = field(default_factory=ReportedMetrics)
|
|
17
|
+
metadata: ReportedMetadata = field(default_factory=ReportedMetadata)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CompletedSolve:
|
|
22
|
+
"""One finished outermost solve call as seen by the observer."""
|
|
23
|
+
|
|
24
|
+
api: str
|
|
25
|
+
solve_id: str
|
|
26
|
+
solve_index: int
|
|
27
|
+
start_time_ms: int
|
|
28
|
+
end_time_ms: int
|
|
29
|
+
wall_time_seconds: float
|
|
30
|
+
observation: SolveObservation | None
|
|
31
|
+
error: BaseException | None
|