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.
Files changed (40) hide show
  1. mlflow_optimization/__init__.py +50 -0
  2. mlflow_optimization/_bootstrap.py +129 -0
  3. mlflow_optimization/common/diagnostics.py +19 -0
  4. mlflow_optimization/common/patching.py +82 -0
  5. mlflow_optimization/features/annotations/annotate.py +97 -0
  6. mlflow_optimization/features/annotations/types.py +22 -0
  7. mlflow_optimization/features/artifacts/policy.py +16 -0
  8. mlflow_optimization/features/artifacts/ports.py +17 -0
  9. mlflow_optimization/features/autolog/ports.py +49 -0
  10. mlflow_optimization/features/autolog/service.py +147 -0
  11. mlflow_optimization/features/autolog/types.py +31 -0
  12. mlflow_optimization/features/metadata/collect.py +52 -0
  13. mlflow_optimization/features/metadata/types.py +42 -0
  14. mlflow_optimization/features/metrics/normalize.py +121 -0
  15. mlflow_optimization/features/metrics/types.py +54 -0
  16. mlflow_optimization/features/outcomes/classify.py +53 -0
  17. mlflow_optimization/features/outcomes/types.py +40 -0
  18. mlflow_optimization/features/runs/ports.py +30 -0
  19. mlflow_optimization/features/runs/record.py +231 -0
  20. mlflow_optimization/features/runs/types.py +43 -0
  21. mlflow_optimization/integrations/cp_sat/__init__.py +5 -0
  22. mlflow_optimization/integrations/cp_sat/adapter.py +464 -0
  23. mlflow_optimization/integrations/gurobipy/__init__.py +5 -0
  24. mlflow_optimization/integrations/gurobipy/adapter.py +474 -0
  25. mlflow_optimization/integrations/mathopt/__init__.py +1 -0
  26. mlflow_optimization/integrations/mathopt/adapter.py +939 -0
  27. mlflow_optimization/integrations/mlflow/artifacts.py +44 -0
  28. mlflow_optimization/integrations/mlflow/runs.py +480 -0
  29. mlflow_optimization/integrations/pyomo/__init__.py +5 -0
  30. mlflow_optimization/integrations/pyomo/adapter.py +1499 -0
  31. mlflow_optimization/integrations/pyscipopt/__init__.py +5 -0
  32. mlflow_optimization/integrations/pyscipopt/adapter.py +657 -0
  33. mlflow_optimization/integrations/routing/__init__.py +5 -0
  34. mlflow_optimization/integrations/routing/adapter.py +429 -0
  35. mlflow_optimization/py.typed +0 -0
  36. mlflow_optimization-0.1.0.dist-info/METADATA +52 -0
  37. mlflow_optimization-0.1.0.dist-info/RECORD +40 -0
  38. mlflow_optimization-0.1.0.dist-info/WHEEL +4 -0
  39. mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-APACHE +176 -0
  40. mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
@@ -0,0 +1,464 @@
1
+ """Observation support for OR-Tools CP-SAT."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+ from typing import Any
6
+
7
+ import ortools
8
+ from ortools.sat import sat_parameters_pb2
9
+ from ortools.sat.python import cp_model
10
+
11
+ from mlflow_optimization.common.patching import Patch, receiver_of
12
+ from mlflow_optimization.features.autolog.ports import (
13
+ ObservationError,
14
+ SolveObserver,
15
+ )
16
+ from mlflow_optimization.features.autolog.types import SolveObservation
17
+ from mlflow_optimization.features.metadata.types import (
18
+ ParameterOrigin,
19
+ ReportedMetadata,
20
+ SolverParameter,
21
+ )
22
+ from mlflow_optimization.features.metrics.types import (
23
+ MissingReason,
24
+ ObjectiveSense,
25
+ ReportedMetrics,
26
+ )
27
+ from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
28
+
29
+
30
+ class CpSatAdapter:
31
+ """The OR-Tools CP-SAT integration."""
32
+
33
+ api: str = "cp_sat"
34
+ _entry_points: tuple[str, ...] = (
35
+ "solve",
36
+ "Solve",
37
+ "SolveWithSolutionCallback",
38
+ "SearchForAllSolutions",
39
+ )
40
+
41
+ def __init__(self) -> None:
42
+ self._observer: SolveObserver | None = None
43
+ self._patches: list[Patch] = []
44
+
45
+ def install(self, observer: SolveObserver) -> None:
46
+ self._observer = observer
47
+ if self._patches:
48
+ return
49
+ for entry_point in self._entry_points:
50
+ if not hasattr(cp_model.CpSolver, entry_point):
51
+ continue
52
+ patch: Patch = Patch(cp_model.CpSolver, entry_point)
53
+ patch.install(self._wrap)
54
+ self._patches.append(patch)
55
+
56
+ def uninstall(self) -> None:
57
+ for patch in reversed(self._patches):
58
+ patch.uninstall()
59
+ self._patches.clear()
60
+ self._observer = None
61
+
62
+ def _wrap(self, original: Any) -> Callable[..., Any]:
63
+ @wraps(original)
64
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
65
+ observer: SolveObserver | None = self._observer
66
+ if observer is None:
67
+ return original(*args, **kwargs)
68
+ solver: Any | None = receiver_of(original, args, kwargs)
69
+ if solver is None:
70
+ return original(*args, **kwargs)
71
+ model: Any | None = kwargs.get("model")
72
+ if model is None and len(args) > 1:
73
+ model = args[1]
74
+
75
+ def call() -> Any:
76
+ return original(*args, **kwargs)
77
+
78
+ def extract(
79
+ result: Any | None,
80
+ error: BaseException | None,
81
+ ) -> SolveObservation:
82
+ return self._extract(solver, model, result, error)
83
+
84
+ return observer.observe(self.api, call, extract)
85
+
86
+ return wrapped
87
+
88
+ def _extract(
89
+ self,
90
+ solver: Any,
91
+ model: Any | None,
92
+ result: Any | None,
93
+ error: BaseException | None,
94
+ ) -> SolveObservation:
95
+ try:
96
+ outcome: ReportedOutcome = self._read_outcome(
97
+ solver,
98
+ model,
99
+ result,
100
+ error,
101
+ )
102
+ metrics: ReportedMetrics = self._read_metrics(
103
+ solver,
104
+ model,
105
+ outcome,
106
+ error is not None,
107
+ )
108
+ metadata: ReportedMetadata = self._read_metadata(solver, model)
109
+ return SolveObservation(self.api, outcome, metrics, metadata)
110
+ except Exception as extraction_error:
111
+ raise ObservationError(
112
+ "Could not build a CP-SAT solve observation."
113
+ ) from extraction_error
114
+
115
+ def _read_outcome(
116
+ self,
117
+ solver: Any,
118
+ model: Any | None,
119
+ result: Any | None,
120
+ error: BaseException | None,
121
+ ) -> ReportedOutcome:
122
+ if error is not None:
123
+ termination: Termination = (
124
+ Termination.INTERRUPTED
125
+ if isinstance(error, KeyboardInterrupt)
126
+ else Termination.ERROR
127
+ )
128
+ raw_status: str = f"{termination.value} ({type(error).__name__})"
129
+ return ReportedOutcome(raw_status, termination, None)
130
+
131
+ raw_status = self._read_raw_status(result)
132
+ if result == cp_model.OPTIMAL:
133
+ termination = Termination.OPTIMAL
134
+ has_solution: bool | None = True
135
+ elif result == cp_model.FEASIBLE:
136
+ termination = Termination.FEASIBLE
137
+ has_solution = True
138
+ elif result == cp_model.INFEASIBLE:
139
+ termination = Termination.INFEASIBLE
140
+ has_solution = False
141
+ elif result == cp_model.MODEL_INVALID:
142
+ termination = Termination.ERROR
143
+ has_solution = False
144
+ elif result == cp_model.UNKNOWN:
145
+ termination = Termination.UNKNOWN
146
+ has_solution = self._read_solution_availability(solver, model)
147
+ else:
148
+ termination = Termination.UNKNOWN
149
+ has_solution = self._read_solution_availability(solver, model)
150
+ return ReportedOutcome(raw_status, termination, has_solution)
151
+
152
+ def _read_raw_status(self, result: Any | None) -> str:
153
+ if result is None:
154
+ return "unknown"
155
+ status_names: dict[Any, str] = {
156
+ cp_model.OPTIMAL: "OPTIMAL",
157
+ cp_model.FEASIBLE: "FEASIBLE",
158
+ cp_model.INFEASIBLE: "INFEASIBLE",
159
+ cp_model.MODEL_INVALID: "MODEL_INVALID",
160
+ cp_model.UNKNOWN: "UNKNOWN",
161
+ }
162
+ status_name: str = status_names.get(result, "unknown")
163
+ if status_name == "unknown":
164
+ try:
165
+ status_name = str(result.name)
166
+ except Exception: # noqa: BLE001
167
+ status_name = "unknown"
168
+ try:
169
+ status_code: int = int(result)
170
+ except Exception: # noqa: BLE001
171
+ return status_name
172
+ if status_name == "unknown":
173
+ return f"unknown ({status_code})"
174
+ return f"{status_name} ({status_code})"
175
+
176
+ def _read_solution_availability(
177
+ self,
178
+ solver: Any,
179
+ model: Any | None,
180
+ ) -> bool | None:
181
+ if model is None:
182
+ return None
183
+ try:
184
+ variable_count: int = len(model.proto.variables)
185
+ except Exception: # noqa: BLE001
186
+ return None
187
+ try:
188
+ solution_count: int = len(solver.response_proto.solution)
189
+ except Exception: # noqa: BLE001
190
+ return None
191
+ if variable_count == 0:
192
+ return None
193
+ return solution_count == variable_count
194
+
195
+ def _read_metrics(
196
+ self,
197
+ solver: Any,
198
+ model: Any | None,
199
+ outcome: ReportedOutcome,
200
+ call_failed: bool,
201
+ ) -> ReportedMetrics:
202
+ has_objective: bool | None = self._read_has_objective(model)
203
+ sense: ObjectiveSense | None = self._read_sense(model, has_objective)
204
+ if has_objective is False:
205
+ objective_value: float | MissingReason = MissingReason.UNSUPPORTED
206
+ dual_bound: float | MissingReason = MissingReason.UNSUPPORTED
207
+ elif call_failed:
208
+ objective_value = MissingReason.FAILED
209
+ dual_bound = MissingReason.FAILED
210
+ elif has_objective is None:
211
+ objective_value = MissingReason.UNDEFINED
212
+ dual_bound = MissingReason.UNDEFINED
213
+ else:
214
+ objective_value = self._read_objective_value(
215
+ solver,
216
+ outcome.has_solution,
217
+ )
218
+ dual_bound = self._read_dual_bound(solver, outcome)
219
+ if call_failed:
220
+ solver_time_seconds: float | MissingReason = MissingReason.FAILED
221
+ native: dict[str, float] = {}
222
+ else:
223
+ solver_time_seconds = self._read_response_metric(solver, "wall_time")
224
+ native = self._read_native_metrics(solver)
225
+ return ReportedMetrics(
226
+ has_objective=has_objective,
227
+ sense=sense,
228
+ objective_value=objective_value,
229
+ dual_bound=dual_bound,
230
+ solver_time_seconds=solver_time_seconds,
231
+ statistics={},
232
+ native=native,
233
+ )
234
+
235
+ def _read_has_objective(self, model: Any | None) -> bool | None:
236
+ if model is None:
237
+ return None
238
+ try:
239
+ model_proto: Any = model.proto
240
+ except Exception: # noqa: BLE001
241
+ return None
242
+ has_floating_point_objective: bool | None = None
243
+ try:
244
+ has_floating_point_objective = bool(
245
+ model_proto.has_floating_point_objective()
246
+ )
247
+ except Exception: # noqa: BLE001,S110
248
+ pass
249
+ if has_floating_point_objective is True:
250
+ return True
251
+ has_integer_objective: bool | None = None
252
+ try:
253
+ has_integer_objective = bool(model_proto.has_objective())
254
+ except Exception: # noqa: BLE001,S110
255
+ pass
256
+ if has_integer_objective is True:
257
+ return True
258
+ if has_floating_point_objective is False and has_integer_objective is False:
259
+ return False
260
+ return None
261
+
262
+ def _read_sense(
263
+ self,
264
+ model: Any | None,
265
+ has_objective: bool | None,
266
+ ) -> ObjectiveSense | None:
267
+ if model is None or has_objective is not True:
268
+ return None
269
+ try:
270
+ model_proto: Any = model.proto
271
+ except Exception: # noqa: BLE001
272
+ return None
273
+ has_floating_point_objective: bool | None = None
274
+ try:
275
+ has_floating_point_objective = bool(
276
+ model_proto.has_floating_point_objective()
277
+ )
278
+ except Exception: # noqa: BLE001,S110
279
+ pass
280
+ if has_floating_point_objective is True:
281
+ try:
282
+ maximize: bool = bool(model_proto.floating_point_objective.maximize)
283
+ except Exception: # noqa: BLE001
284
+ return None
285
+ return ObjectiveSense.MAXIMIZE if maximize else ObjectiveSense.MINIMIZE
286
+ if has_floating_point_objective is not False:
287
+ return None
288
+ try:
289
+ scaling_factor: float = float(model_proto.objective.scaling_factor)
290
+ except Exception: # noqa: BLE001
291
+ return None
292
+ if scaling_factor < 0:
293
+ return ObjectiveSense.MAXIMIZE
294
+ if scaling_factor > 0:
295
+ return ObjectiveSense.MINIMIZE
296
+ return None
297
+
298
+ def _read_objective_value(
299
+ self,
300
+ solver: Any,
301
+ has_solution: bool | None,
302
+ ) -> float | MissingReason:
303
+ if has_solution is False:
304
+ return MissingReason.NO_SOLUTION
305
+ if has_solution is None:
306
+ return MissingReason.UNDEFINED
307
+ return self._read_response_metric(solver, "objective_value")
308
+
309
+ def _read_dual_bound(
310
+ self,
311
+ solver: Any,
312
+ outcome: ReportedOutcome,
313
+ ) -> float | MissingReason:
314
+ if outcome.has_solution is True:
315
+ return self._read_response_metric(solver, "best_objective_bound")
316
+ if outcome.termination is not Termination.UNKNOWN:
317
+ return MissingReason.UNDEFINED
318
+ value: float | MissingReason = self._read_response_metric(
319
+ solver,
320
+ "best_objective_bound",
321
+ )
322
+ if isinstance(value, MissingReason):
323
+ return value
324
+ if value != 0.0:
325
+ return value
326
+ return MissingReason.UNDEFINED
327
+
328
+ def _read_response_metric(
329
+ self,
330
+ solver: Any,
331
+ name: str,
332
+ ) -> float | MissingReason:
333
+ try:
334
+ response: Any = solver.response_proto
335
+ value: float = float(getattr(response, name))
336
+ except Exception: # noqa: BLE001
337
+ return MissingReason.FAILED
338
+ return value
339
+
340
+ def _read_native_metrics(self, solver: Any) -> dict[str, float]:
341
+ native_fields: tuple[str, ...] = (
342
+ "user_time",
343
+ "deterministic_time",
344
+ "num_branches",
345
+ "num_conflicts",
346
+ "num_booleans",
347
+ "num_integers",
348
+ "num_fixed_booleans",
349
+ "num_binary_propagations",
350
+ "num_integer_propagations",
351
+ "num_restarts",
352
+ "num_lp_iterations",
353
+ "gap_integral",
354
+ )
355
+ native: dict[str, float] = {}
356
+ for name in native_fields:
357
+ metric_value: float | MissingReason = self._read_response_metric(
358
+ solver,
359
+ name,
360
+ )
361
+ if not isinstance(metric_value, MissingReason):
362
+ native[name] = metric_value
363
+ return native
364
+
365
+ def _read_metadata(self, solver: Any, model: Any | None) -> ReportedMetadata:
366
+ problem: dict[str, int] = self._read_problem_size(model)
367
+ parameters: tuple[SolverParameter, ...] = self._read_parameters(solver)
368
+ return ReportedMetadata(
369
+ solver="cp_sat",
370
+ api_version=ortools.__version__,
371
+ solver_version=ortools.__version__,
372
+ problem=problem,
373
+ parameters=parameters,
374
+ wall_time_includes_problem_build=False,
375
+ )
376
+
377
+ def _read_problem_size(self, model: Any | None) -> dict[str, int]:
378
+ if model is None:
379
+ return {}
380
+ try:
381
+ model_proto: Any = model.proto
382
+ except Exception: # noqa: BLE001
383
+ return {}
384
+ problem: dict[str, int] = {}
385
+ variables: Any | None = None
386
+ try:
387
+ variables = model_proto.variables
388
+ if variables is not None:
389
+ variable_count: int = len(variables)
390
+ problem["num_variables"] = variable_count
391
+ problem["num_integer_variables"] = variable_count
392
+ except Exception: # noqa: BLE001
393
+ variables = None
394
+ try:
395
+ problem["num_constraints"] = len(model_proto.constraints)
396
+ except Exception: # noqa: BLE001,S110
397
+ pass
398
+ if variables is not None:
399
+ boolean_variable_count: int = 0
400
+ boolean_variable_count_known: bool = True
401
+ for variable in variables:
402
+ try:
403
+ domain: list[int] = list(variable.domain)
404
+ if domain == [0, 1]:
405
+ boolean_variable_count += 1
406
+ except Exception: # noqa: BLE001
407
+ boolean_variable_count_known = False
408
+ if boolean_variable_count_known:
409
+ problem["num_boolean_variables"] = boolean_variable_count
410
+ return problem
411
+
412
+ def _read_parameters(self, solver: Any) -> tuple[SolverParameter, ...]:
413
+ try:
414
+ configured_parameters: Any = solver.parameters
415
+ default_parameters: Any = sat_parameters_pb2.SatParameters()
416
+ except Exception: # noqa: BLE001
417
+ return ()
418
+ parameters: list[SolverParameter] = []
419
+ parameter: SolverParameter | None
420
+ for field in sat_parameters_pb2.SatParameters.DESCRIPTOR.fields:
421
+ parameter = None
422
+ try:
423
+ configured_value: Any = getattr(configured_parameters, field.name)
424
+ default_value: Any = getattr(default_parameters, field.name)
425
+ if field.is_repeated:
426
+ configured_value = list(configured_value)
427
+ default_value = list(default_value)
428
+ if configured_value == default_value:
429
+ continue
430
+ value: int | float | str | bool = self._parameter_value(
431
+ configured_value,
432
+ field,
433
+ )
434
+ parameter = SolverParameter(field.name, value, ParameterOrigin.EXPLICIT)
435
+ except Exception: # noqa: BLE001
436
+ parameter = None
437
+ if parameter is not None:
438
+ parameters.append(parameter)
439
+ return tuple(parameters)
440
+
441
+ def _parameter_value(self, value: Any, field: Any) -> int | float | str | bool:
442
+ if field.is_repeated:
443
+ if field.enum_type is None:
444
+ return str(list(value))
445
+ enum_values: list[str | int] = []
446
+ for number in value:
447
+ repeated_enum_value: Any | None = field.enum_type.values_by_number.get(
448
+ number
449
+ )
450
+ enum_values.append(
451
+ str(repeated_enum_value.name)
452
+ if repeated_enum_value is not None
453
+ else number
454
+ )
455
+ return str(enum_values)
456
+ if field.enum_type is not None:
457
+ number: int = int(value)
458
+ enum_value: Any | None = field.enum_type.values_by_number.get(number)
459
+ if enum_value is not None:
460
+ return str(enum_value.name)
461
+ return number
462
+ if isinstance(value, int | float | str):
463
+ return value
464
+ return str(value)
@@ -0,0 +1,5 @@
1
+ """Gurobi solver integration."""
2
+
3
+ from .adapter import GurobiAdapter
4
+
5
+ __all__ = ("GurobiAdapter",)