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,474 @@
1
+ """Observation of Gurobi solve calls."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+ from math import inf
6
+ from typing import Any
7
+
8
+ import gurobipy
9
+ from mlflow_optimization.common.patching import Patch, receiver_of
10
+ from mlflow_optimization.features.autolog.ports import (
11
+ ObservationError,
12
+ SolveObserver,
13
+ SolverIntegration,
14
+ )
15
+ from mlflow_optimization.features.autolog.types import SolveObservation
16
+ from mlflow_optimization.features.metadata.types import (
17
+ ParameterOrigin,
18
+ ReportedMetadata,
19
+ SolverParameter,
20
+ )
21
+ from mlflow_optimization.features.metrics.types import (
22
+ MissingReason,
23
+ ObjectiveSense,
24
+ ReportedMetrics,
25
+ )
26
+ from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
27
+
28
+ _STATUS_TERMINATIONS: dict[int, tuple[str, Termination]] = {
29
+ gurobipy.GRB.LOADED: ("LOADED", Termination.UNKNOWN),
30
+ gurobipy.GRB.OPTIMAL: ("OPTIMAL", Termination.OPTIMAL),
31
+ gurobipy.GRB.INFEASIBLE: ("INFEASIBLE", Termination.INFEASIBLE),
32
+ gurobipy.GRB.INF_OR_UNBD: ("INF_OR_UNBD", Termination.INFEASIBLE_OR_UNBOUNDED),
33
+ gurobipy.GRB.UNBOUNDED: ("UNBOUNDED", Termination.UNBOUNDED),
34
+ gurobipy.GRB.CUTOFF: ("CUTOFF", Termination.OTHER_LIMIT),
35
+ gurobipy.GRB.ITERATION_LIMIT: ("ITERATION_LIMIT", Termination.ITERATION_LIMIT),
36
+ gurobipy.GRB.NODE_LIMIT: ("NODE_LIMIT", Termination.NODE_LIMIT),
37
+ gurobipy.GRB.TIME_LIMIT: ("TIME_LIMIT", Termination.TIME_LIMIT),
38
+ gurobipy.GRB.SOLUTION_LIMIT: ("SOLUTION_LIMIT", Termination.SOLUTION_LIMIT),
39
+ gurobipy.GRB.INTERRUPTED: ("INTERRUPTED", Termination.INTERRUPTED),
40
+ gurobipy.GRB.NUMERIC: ("NUMERIC", Termination.UNKNOWN),
41
+ gurobipy.GRB.SUBOPTIMAL: ("SUBOPTIMAL", Termination.FEASIBLE),
42
+ gurobipy.GRB.INPROGRESS: ("INPROGRESS", Termination.UNKNOWN),
43
+ gurobipy.GRB.USER_OBJ_LIMIT: ("USER_OBJ_LIMIT", Termination.OTHER_LIMIT),
44
+ gurobipy.GRB.WORK_LIMIT: ("WORK_LIMIT", Termination.OTHER_LIMIT),
45
+ gurobipy.GRB.MEM_LIMIT: ("MEM_LIMIT", Termination.OTHER_LIMIT),
46
+ }
47
+
48
+
49
+ class GurobiAdapter(SolverIntegration):
50
+ """The Gurobi solver integration."""
51
+
52
+ api: str = "gurobipy"
53
+
54
+ def __init__(self) -> None:
55
+ self._patch: Patch = Patch(gurobipy.Model, "optimize")
56
+
57
+ def install(self, observer: SolveObserver) -> None:
58
+ def factory(original: Any) -> Callable[..., Any]:
59
+ @wraps(original)
60
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
61
+ solve_observer: SolveObserver = observer
62
+ receiver: Any = receiver_of(original, args, kwargs)
63
+ if receiver is None:
64
+ return original(*args, **kwargs)
65
+
66
+ def call() -> Any:
67
+ return original(*args, **kwargs)
68
+
69
+ def extract(
70
+ result: Any | None, error: BaseException | None
71
+ ) -> SolveObservation:
72
+ if not isinstance(receiver, gurobipy.Model):
73
+ raise ObservationError(
74
+ "Could not find the Gurobi model for this solve."
75
+ ) from error
76
+ model: gurobipy.Model = receiver
77
+ try:
78
+ return _extract(model, error)
79
+ except Exception as observation_error:
80
+ raise ObservationError(
81
+ "Could not build a Gurobi solve observation."
82
+ ) from observation_error
83
+
84
+ return solve_observer.observe(self.api, call, extract)
85
+
86
+ return wrapped
87
+
88
+ self._patch.install(factory)
89
+
90
+ def uninstall(self) -> None:
91
+ self._patch.uninstall()
92
+
93
+
94
+ def _extract(model: gurobipy.Model, error: BaseException | None) -> SolveObservation:
95
+ metadata: ReportedMetadata = _read_metadata(model)
96
+ if error is not None:
97
+ error_termination: Termination = (
98
+ Termination.INTERRUPTED
99
+ if isinstance(error, KeyboardInterrupt)
100
+ else Termination.ERROR
101
+ )
102
+ error_status: str = (
103
+ "INTERRUPTED" if isinstance(error, KeyboardInterrupt) else "ERROR"
104
+ )
105
+ error_outcome: ReportedOutcome = ReportedOutcome(
106
+ error_status, error_termination, None
107
+ )
108
+ error_metrics: ReportedMetrics = ReportedMetrics()
109
+ return SolveObservation(
110
+ api="gurobipy",
111
+ outcome=error_outcome,
112
+ metrics=error_metrics,
113
+ metadata=metadata,
114
+ )
115
+ raw_status: str
116
+ termination: Termination
117
+ raw_status, termination = _read_status(model)
118
+ solution_count: float | MissingReason = _read_solver_number(model, "SolCount")
119
+ has_solution: bool | None
120
+ if isinstance(solution_count, MissingReason):
121
+ has_solution = None
122
+ else:
123
+ has_solution = solution_count > 0
124
+ has_objective: bool | None = _read_has_objective(model)
125
+ sense: ObjectiveSense | None = _read_sense(model)
126
+ objective_value: float | MissingReason = _read_objective_value(
127
+ model, has_objective, has_solution
128
+ )
129
+ dual_bound: float | MissingReason = _read_dual_bound(model, has_objective)
130
+ solver_time_seconds: float | MissingReason = _read_solver_number(model, "Runtime")
131
+ statistics: dict[str, float | MissingReason] = {
132
+ "node_count": _read_solver_number(model, "NodeCount"),
133
+ "simplex_iterations": _read_solver_number(model, "IterCount"),
134
+ "barrier_iterations": _read_solver_number(model, "BarIterCount"),
135
+ }
136
+ native: dict[str, float] = {}
137
+ if not isinstance(solution_count, MissingReason):
138
+ native["solution_count"] = solution_count
139
+ for attribute_name, metric_name in (("MIPGap", "mip_gap"), ("Work", "work")):
140
+ metric_value: float | MissingReason = _read_solver_number(model, attribute_name)
141
+ if not isinstance(metric_value, MissingReason):
142
+ native[metric_name] = metric_value
143
+ outcome: ReportedOutcome = ReportedOutcome(raw_status, termination, has_solution)
144
+ metrics: ReportedMetrics = ReportedMetrics(
145
+ has_objective=has_objective,
146
+ sense=sense,
147
+ objective_value=objective_value,
148
+ dual_bound=dual_bound,
149
+ solver_time_seconds=solver_time_seconds,
150
+ statistics=statistics,
151
+ native=native,
152
+ )
153
+ return SolveObservation(
154
+ api="gurobipy", outcome=outcome, metrics=metrics, metadata=metadata
155
+ )
156
+
157
+
158
+ def _read_metadata(model: gurobipy.Model) -> ReportedMetadata:
159
+ problem: dict[str, int] = {}
160
+ for attribute_name, problem_name in (
161
+ ("NumVars", "num_variables"),
162
+ ("NumConstrs", "num_constraints"),
163
+ ("NumIntVars", "num_integer_variables"),
164
+ ("NumQConstrs", "num_quadratic_constraints"),
165
+ ("NumGenConstrs", "num_general_constraints"),
166
+ ("NumSOS", "num_sos_constraints"),
167
+ ("NumNZs", "num_nonzeros"),
168
+ ):
169
+ problem_value: int | None = _read_problem_size(model, attribute_name)
170
+ if problem_value is not None:
171
+ problem[problem_name] = problem_value
172
+ api_version: str | None = _read_api_version()
173
+ solver_version: str | None = _read_solver_version()
174
+ parameters: tuple[SolverParameter, ...] = _read_parameters(model)
175
+ return ReportedMetadata(
176
+ solver="gurobi",
177
+ api_version=api_version,
178
+ solver_version=solver_version,
179
+ problem=problem,
180
+ parameters=parameters,
181
+ wall_time_includes_problem_build=False,
182
+ )
183
+
184
+
185
+ def _read_attribute(model: gurobipy.Model, attribute_name: str) -> tuple[bool, Any]:
186
+ try:
187
+ value: Any = getattr(model, attribute_name)
188
+ except Exception: # noqa: BLE001
189
+ return False, None
190
+ return True, value
191
+
192
+
193
+ def _read_status(model: gurobipy.Model) -> tuple[str, Termination]:
194
+ readable: bool
195
+ raw_value: Any
196
+ readable, raw_value = _read_attribute(model, "Status")
197
+ if not readable:
198
+ return "UNKNOWN", Termination.UNKNOWN
199
+ try:
200
+ status_code: int = int(raw_value)
201
+ except Exception: # noqa: BLE001
202
+ return "UNKNOWN", Termination.UNKNOWN
203
+ status_name: str
204
+ termination: Termination
205
+ status_name, termination = _STATUS_TERMINATIONS.get(
206
+ status_code, ("UNKNOWN", Termination.UNKNOWN)
207
+ )
208
+ return f"{status_name} ({status_code})", termination
209
+
210
+
211
+ def _read_solver_number(
212
+ model: gurobipy.Model, attribute_name: str
213
+ ) -> float | MissingReason:
214
+ readable: bool
215
+ raw_value: Any
216
+ readable, raw_value = _read_attribute(model, attribute_name)
217
+ if not readable:
218
+ return MissingReason.FAILED
219
+ try:
220
+ value: float = float(raw_value)
221
+ solver_infinity: float = float(gurobipy.GRB.INFINITY)
222
+ except Exception: # noqa: BLE001
223
+ return MissingReason.FAILED
224
+ if value == solver_infinity:
225
+ return inf
226
+ if value == -solver_infinity:
227
+ return -inf
228
+ return value
229
+
230
+
231
+ def _read_problem_size(model: gurobipy.Model, attribute_name: str) -> int | None:
232
+ readable: bool
233
+ raw_value: Any
234
+ readable, raw_value = _read_attribute(model, attribute_name)
235
+ if not readable:
236
+ return None
237
+ try:
238
+ return int(raw_value)
239
+ except Exception: # noqa: BLE001
240
+ return None
241
+
242
+
243
+ def _read_has_objective(model: gurobipy.Model) -> bool | None:
244
+ objective_count: int | None = _read_problem_size(model, "NumObj")
245
+ if objective_count is None:
246
+ return None
247
+ if objective_count <= 0:
248
+ return False
249
+ objective_indices: tuple[int | None, ...]
250
+ if objective_count == 1:
251
+ objective_indices = (None,)
252
+ else:
253
+ objective_indices = tuple(range(objective_count))
254
+ objective_unavailable: bool = False
255
+ for objective_index in objective_indices:
256
+ try:
257
+ objective: Any
258
+ if objective_index is None:
259
+ objective = model.getObjective()
260
+ else:
261
+ objective = model.getObjective(objective_index)
262
+ except Exception: # noqa: BLE001
263
+ objective_unavailable = True
264
+ continue
265
+ objective_has_content: bool | None = _objective_expression_has_content(
266
+ objective
267
+ )
268
+ if objective_has_content is True:
269
+ return True
270
+ if objective_has_content is None:
271
+ objective_unavailable = True
272
+ piecewise_objective: bool | None = _has_piecewise_linear_objective(model)
273
+ if piecewise_objective is True:
274
+ return True
275
+ if piecewise_objective is None:
276
+ objective_unavailable = True
277
+ if objective_unavailable:
278
+ return None
279
+ return False
280
+
281
+
282
+ def _has_piecewise_linear_objective(model: gurobipy.Model) -> bool | None:
283
+ try:
284
+ variables: list[gurobipy.Var] = model.getVars()
285
+ except Exception: # noqa: BLE001
286
+ return None
287
+ objective_unavailable: bool = False
288
+ for variable in variables:
289
+ try:
290
+ points: Any = model.getPWLObj(variable)
291
+ except Exception: # noqa: BLE001
292
+ objective_unavailable = True
293
+ continue
294
+ if points:
295
+ return True
296
+ if objective_unavailable:
297
+ return None
298
+ return False
299
+
300
+
301
+ def _objective_expression_has_content(objective: Any) -> bool | None:
302
+ quadratic_term_count: int | None
303
+ linear_expression: Any
304
+ if isinstance(objective, gurobipy.QuadExpr):
305
+ try:
306
+ quadratic_term_count = int(objective.size())
307
+ except Exception: # noqa: BLE001
308
+ quadratic_term_count = None
309
+ try:
310
+ linear_expression = objective.getLinExpr()
311
+ except Exception: # noqa: BLE001
312
+ linear_expression = None
313
+ elif isinstance(objective, gurobipy.LinExpr):
314
+ quadratic_term_count = 0
315
+ linear_expression = objective
316
+ elif isinstance(objective, gurobipy.NLExpr):
317
+ return True
318
+ else:
319
+ return None
320
+ linear_term_count: int | None
321
+ if linear_expression is None:
322
+ linear_term_count = None
323
+ constant_value: float | None = None
324
+ else:
325
+ try:
326
+ linear_term_count = int(linear_expression.size())
327
+ except Exception: # noqa: BLE001
328
+ linear_term_count = None
329
+ try:
330
+ constant_value = float(linear_expression.getConstant())
331
+ except Exception: # noqa: BLE001
332
+ constant_value = None
333
+ if quadratic_term_count is not None and quadratic_term_count > 0:
334
+ return True
335
+ if linear_term_count is not None and linear_term_count > 0:
336
+ return True
337
+ if constant_value is not None and constant_value != 0.0:
338
+ return True
339
+ if (
340
+ quadratic_term_count is None
341
+ or linear_term_count is None
342
+ or constant_value is None
343
+ ):
344
+ return None
345
+ return False
346
+
347
+
348
+ def _read_sense(model: gurobipy.Model) -> ObjectiveSense | None:
349
+ sense_value: int | None = _read_problem_size(model, "ModelSense")
350
+ if sense_value == gurobipy.GRB.MINIMIZE:
351
+ return ObjectiveSense.MINIMIZE
352
+ if sense_value == gurobipy.GRB.MAXIMIZE:
353
+ return ObjectiveSense.MAXIMIZE
354
+ return None
355
+
356
+
357
+ def _read_objective_value(
358
+ model: gurobipy.Model,
359
+ has_objective: bool | None,
360
+ has_solution: bool | None,
361
+ ) -> float | MissingReason:
362
+ if has_objective is False:
363
+ return MissingReason.UNDEFINED
364
+ if has_solution is False:
365
+ return MissingReason.NO_SOLUTION
366
+ return _read_solver_number(model, "ObjVal")
367
+
368
+
369
+ def _read_dual_bound(
370
+ model: gurobipy.Model, has_objective: bool | None
371
+ ) -> float | MissingReason:
372
+ if has_objective is False:
373
+ return MissingReason.UNDEFINED
374
+ return _read_solver_number(model, "ObjBound")
375
+
376
+
377
+ def _read_parameters(model: gurobipy.Model) -> tuple[SolverParameter, ...]:
378
+ readable: bool
379
+ parameter_set: Any
380
+ readable, parameter_set = _read_attribute(model, "Params")
381
+ if not readable:
382
+ return ()
383
+ parameter_names: list[str] = []
384
+ try:
385
+ available_parameter_names: list[str] = dir(parameter_set)
386
+ except Exception: # noqa: BLE001
387
+ return ()
388
+ for parameter_name in available_parameter_names:
389
+ if parameter_name and parameter_name[0].isupper():
390
+ parameter_names.append(parameter_name)
391
+ parameters: list[SolverParameter] = []
392
+ for parameter_name in parameter_names:
393
+ try:
394
+ parameter_info: Any = model.getParamInfo(parameter_name)
395
+ except Exception: # noqa: BLE001,S112
396
+ continue
397
+ try:
398
+ parameter_type: Any = parameter_info[1]
399
+ except Exception: # noqa: BLE001,S112
400
+ continue
401
+ # NOTE: LicenseID identifies a WLS license.
402
+ if parameter_type is str or parameter_name == "LicenseID":
403
+ continue
404
+ try:
405
+ current_parameter_value: Any = parameter_info[2]
406
+ except Exception: # noqa: BLE001,S112
407
+ continue
408
+ try:
409
+ default_parameter_value: Any = parameter_info[5]
410
+ except Exception: # noqa: BLE001,S112
411
+ continue
412
+ current_value: int | float | str | bool | None = _plain_parameter_value(
413
+ current_parameter_value
414
+ )
415
+ default_value: int | float | str | bool | None = _plain_parameter_value(
416
+ default_parameter_value
417
+ )
418
+ if (
419
+ current_value is None
420
+ or default_value is None
421
+ or current_value == default_value
422
+ ):
423
+ continue
424
+ parameters.append(
425
+ SolverParameter(
426
+ name=parameter_name,
427
+ value=current_value,
428
+ origin=ParameterOrigin.EXPLICIT,
429
+ )
430
+ )
431
+ return tuple(parameters)
432
+
433
+
434
+ def _plain_parameter_value(value: Any) -> int | float | str | bool | None:
435
+ if isinstance(value, (int, float, str)):
436
+ return value
437
+ return None
438
+
439
+
440
+ def _read_api_version() -> str | None:
441
+ try:
442
+ version_value: Any = getattr(gurobipy, "__version__", None)
443
+ except Exception: # noqa: BLE001
444
+ return None
445
+ if isinstance(version_value, str):
446
+ return version_value
447
+ return None
448
+
449
+
450
+ def _read_solver_version() -> str | None:
451
+ try:
452
+ solver_module: Any = getattr(gurobipy, "gurobi", None)
453
+ except Exception: # noqa: BLE001
454
+ return None
455
+ if solver_module is None:
456
+ return None
457
+ try:
458
+ version_function: Any = getattr(solver_module, "version", None)
459
+ except Exception: # noqa: BLE001
460
+ return None
461
+ if not callable(version_function):
462
+ return None
463
+ try:
464
+ version: Any = version_function()
465
+ except Exception: # noqa: BLE001
466
+ return None
467
+ if not isinstance(version, tuple):
468
+ return None
469
+ version_parts: list[str] = []
470
+ for version_part in version:
471
+ if not isinstance(version_part, int):
472
+ return None
473
+ version_parts.append(str(version_part))
474
+ return ".".join(version_parts)
@@ -0,0 +1 @@
1
+ """MathOpt solver integration."""