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,657 @@
|
|
|
1
|
+
"""The PySCIPOpt solver integration."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from inspect import Signature, signature
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any
|
|
9
|
+
from weakref import WeakKeyDictionary
|
|
10
|
+
|
|
11
|
+
import pyscipopt
|
|
12
|
+
import pyscipopt.scip
|
|
13
|
+
from mlflow_optimization.common.patching import (
|
|
14
|
+
rebind_module_names,
|
|
15
|
+
receiver_of,
|
|
16
|
+
restore_module_names,
|
|
17
|
+
)
|
|
18
|
+
from mlflow_optimization.features.autolog.ports import ObservationError, SolveObserver
|
|
19
|
+
from mlflow_optimization.features.autolog.types import SolveObservation
|
|
20
|
+
from mlflow_optimization.features.metadata.types import (
|
|
21
|
+
ParameterOrigin,
|
|
22
|
+
ReportedMetadata,
|
|
23
|
+
SolverParameter,
|
|
24
|
+
)
|
|
25
|
+
from mlflow_optimization.features.metrics.types import (
|
|
26
|
+
MissingReason,
|
|
27
|
+
ObjectiveSense,
|
|
28
|
+
ReportedMetrics,
|
|
29
|
+
)
|
|
30
|
+
from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
|
|
31
|
+
|
|
32
|
+
_STATUS_TERMINATIONS: dict[str, Termination] = {
|
|
33
|
+
"unknown": Termination.UNKNOWN,
|
|
34
|
+
"optimal": Termination.OPTIMAL,
|
|
35
|
+
"infeasible": Termination.INFEASIBLE,
|
|
36
|
+
"unbounded": Termination.UNBOUNDED,
|
|
37
|
+
"inforunbd": Termination.INFEASIBLE_OR_UNBOUNDED,
|
|
38
|
+
"userinterrupt": Termination.INTERRUPTED,
|
|
39
|
+
"terminate": Termination.INTERRUPTED,
|
|
40
|
+
"nodelimit": Termination.NODE_LIMIT,
|
|
41
|
+
"totalnodelimit": Termination.NODE_LIMIT,
|
|
42
|
+
"stallnodelimit": Termination.NODE_LIMIT,
|
|
43
|
+
"timelimit": Termination.TIME_LIMIT,
|
|
44
|
+
"memlimit": Termination.OTHER_LIMIT,
|
|
45
|
+
"gaplimit": Termination.OTHER_LIMIT,
|
|
46
|
+
"primallimit": Termination.OTHER_LIMIT,
|
|
47
|
+
"duallimit": Termination.OTHER_LIMIT,
|
|
48
|
+
"sollimit": Termination.SOLUTION_LIMIT,
|
|
49
|
+
"bestsollimit": Termination.SOLUTION_LIMIT,
|
|
50
|
+
"restartlimit": Termination.OTHER_LIMIT,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PyScipOptAdapter:
|
|
55
|
+
"""The PySCIPOpt solver integration."""
|
|
56
|
+
|
|
57
|
+
api: str = "pyscipopt"
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self._installed: bool = False
|
|
61
|
+
self._observer: SolveObserver | None = None
|
|
62
|
+
self._original_model: type[Any] | None = None
|
|
63
|
+
self._logging_model: type[Any] | None = None
|
|
64
|
+
self._rebound_names: list[tuple[ModuleType, str]] = []
|
|
65
|
+
self._counter_snapshots: WeakKeyDictionary[
|
|
66
|
+
Any,
|
|
67
|
+
tuple[int | MissingReason, dict[str, float | MissingReason]],
|
|
68
|
+
] = WeakKeyDictionary()
|
|
69
|
+
|
|
70
|
+
def install(self, observer: SolveObserver) -> None:
|
|
71
|
+
"""A patch that replaces model references with an observing subclass."""
|
|
72
|
+
if self._installed:
|
|
73
|
+
self._observer = observer
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
original_model: type[Any] = pyscipopt.Model
|
|
77
|
+
integration: PyScipOptAdapter = self
|
|
78
|
+
default_parameters: dict[str, bool | int | float | str] | None = (
|
|
79
|
+
_read_default_parameters(original_model)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
class LoggingModel(original_model):
|
|
83
|
+
"""A PySCIPOpt model that observes optimization calls."""
|
|
84
|
+
|
|
85
|
+
entry_point_names: tuple[str, ...] = (
|
|
86
|
+
"optimize",
|
|
87
|
+
"optimizeNogil",
|
|
88
|
+
"solveConcurrent",
|
|
89
|
+
)
|
|
90
|
+
for entry_point_name in entry_point_names:
|
|
91
|
+
original_entry_point: Any = getattr(original_model, entry_point_name, None)
|
|
92
|
+
if not callable(original_entry_point):
|
|
93
|
+
continue
|
|
94
|
+
observed_entry_point: Callable[..., Any] = _wrap_solve_entry_point(
|
|
95
|
+
original_entry_point,
|
|
96
|
+
integration,
|
|
97
|
+
default_parameters,
|
|
98
|
+
)
|
|
99
|
+
setattr(LoggingModel, entry_point_name, observed_entry_point)
|
|
100
|
+
|
|
101
|
+
logging_model: type[Any] = LoggingModel
|
|
102
|
+
rebound_names: list[tuple[ModuleType, str]] = rebind_module_names(
|
|
103
|
+
original_model,
|
|
104
|
+
logging_model,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._observer = observer
|
|
108
|
+
self._original_model = original_model
|
|
109
|
+
self._logging_model = logging_model
|
|
110
|
+
self._rebound_names = rebound_names
|
|
111
|
+
self._installed = True
|
|
112
|
+
|
|
113
|
+
def uninstall(self) -> None:
|
|
114
|
+
"""The restoration of original PySCIPOpt model references."""
|
|
115
|
+
if not self._installed:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
assert self._original_model is not None
|
|
119
|
+
assert self._logging_model is not None
|
|
120
|
+
original_model: type[Any] = self._original_model
|
|
121
|
+
logging_model: type[Any] = self._logging_model
|
|
122
|
+
restore_module_names(self._rebound_names, original_model, logging_model)
|
|
123
|
+
rebind_module_names(logging_model, original_model)
|
|
124
|
+
|
|
125
|
+
self._installed = False
|
|
126
|
+
self._observer = None
|
|
127
|
+
self._original_model = None
|
|
128
|
+
self._logging_model = None
|
|
129
|
+
self._rebound_names = []
|
|
130
|
+
self._counter_snapshots.clear()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _wrap_solve_entry_point(
|
|
134
|
+
original_entry_point: Callable[..., Any],
|
|
135
|
+
integration: PyScipOptAdapter,
|
|
136
|
+
default_parameters: dict[str, bool | int | float | str] | None,
|
|
137
|
+
) -> Callable[..., Any]:
|
|
138
|
+
"""An observed wrapper for one original PySCIPOpt solve entry point."""
|
|
139
|
+
original_signature: Signature = signature(original_entry_point)
|
|
140
|
+
|
|
141
|
+
@wraps(original_entry_point)
|
|
142
|
+
def observed_entry_point(
|
|
143
|
+
*args: Any,
|
|
144
|
+
**kwargs: Any,
|
|
145
|
+
) -> Any:
|
|
146
|
+
observer: SolveObserver | None = integration._observer
|
|
147
|
+
model: Any = receiver_of(original_entry_point, args, kwargs)
|
|
148
|
+
if model is None or observer is None:
|
|
149
|
+
return original_entry_point(*args, **kwargs)
|
|
150
|
+
original_signature.bind(*args, **kwargs)
|
|
151
|
+
counter_snapshots: WeakKeyDictionary[
|
|
152
|
+
Any,
|
|
153
|
+
tuple[int | MissingReason, dict[str, float | MissingReason]],
|
|
154
|
+
] = integration._counter_snapshots
|
|
155
|
+
stage_before_solve: int | MissingReason = _read_value(
|
|
156
|
+
lambda: int(model.getStage()),
|
|
157
|
+
MissingReason.FAILED,
|
|
158
|
+
)
|
|
159
|
+
counter_snapshot: (
|
|
160
|
+
tuple[
|
|
161
|
+
int | MissingReason,
|
|
162
|
+
dict[str, float | MissingReason],
|
|
163
|
+
]
|
|
164
|
+
| None
|
|
165
|
+
) = None
|
|
166
|
+
counter_cache_available: bool = True
|
|
167
|
+
try:
|
|
168
|
+
counter_snapshot = counter_snapshots.get(model)
|
|
169
|
+
except Exception: # noqa: BLE001
|
|
170
|
+
counter_cache_available = False
|
|
171
|
+
counters_before_solve: dict[str, float | MissingReason] | None = None
|
|
172
|
+
if not counter_cache_available or isinstance(
|
|
173
|
+
stage_before_solve,
|
|
174
|
+
MissingReason,
|
|
175
|
+
):
|
|
176
|
+
counters_before_solve = _unavailable_solve_counters()
|
|
177
|
+
elif counter_snapshot is None:
|
|
178
|
+
if stage_before_solve != pyscipopt.scip.PY_SCIP_STAGE.PROBLEM:
|
|
179
|
+
counters_before_solve = _unavailable_solve_counters()
|
|
180
|
+
else:
|
|
181
|
+
previous_stage: int | MissingReason = counter_snapshot[0]
|
|
182
|
+
if isinstance(previous_stage, MissingReason):
|
|
183
|
+
counters_before_solve = _unavailable_solve_counters()
|
|
184
|
+
elif stage_before_solve == previous_stage:
|
|
185
|
+
counters_before_solve = counter_snapshot[1]
|
|
186
|
+
elif stage_before_solve != pyscipopt.scip.PY_SCIP_STAGE.PROBLEM:
|
|
187
|
+
counters_before_solve = _unavailable_solve_counters()
|
|
188
|
+
|
|
189
|
+
# NOTE: SCIP changes parameters while solving, so capture configured values first.
|
|
190
|
+
parameters_before_solve: tuple[SolverParameter, ...] = _read_parameters(
|
|
191
|
+
model,
|
|
192
|
+
default_parameters,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def call() -> Any:
|
|
196
|
+
return original_entry_point(*args, **kwargs)
|
|
197
|
+
|
|
198
|
+
def extract(
|
|
199
|
+
result: Any | None, error: BaseException | None
|
|
200
|
+
) -> SolveObservation:
|
|
201
|
+
try:
|
|
202
|
+
counters_after_solve: dict[str, float | MissingReason] | None = None
|
|
203
|
+
if error is None:
|
|
204
|
+
counters_after_solve = _read_solve_counters(model)
|
|
205
|
+
stage_after_solve: int | MissingReason = _read_value(
|
|
206
|
+
lambda: int(model.getStage()),
|
|
207
|
+
MissingReason.FAILED,
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
counter_snapshots[model] = (
|
|
211
|
+
stage_after_solve,
|
|
212
|
+
counters_after_solve,
|
|
213
|
+
)
|
|
214
|
+
except Exception: # noqa: BLE001
|
|
215
|
+
counter_snapshots.clear()
|
|
216
|
+
else:
|
|
217
|
+
try:
|
|
218
|
+
counter_snapshots[model] = (
|
|
219
|
+
MissingReason.FAILED,
|
|
220
|
+
_unavailable_solve_counters(),
|
|
221
|
+
)
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
counter_snapshots.clear()
|
|
224
|
+
return _extract_observation(
|
|
225
|
+
model,
|
|
226
|
+
error,
|
|
227
|
+
parameters_before_solve,
|
|
228
|
+
counters_before_solve,
|
|
229
|
+
counters_after_solve,
|
|
230
|
+
)
|
|
231
|
+
except Exception as extraction_error:
|
|
232
|
+
raise ObservationError(
|
|
233
|
+
"Could not build a PySCIPOpt solve observation."
|
|
234
|
+
) from extraction_error
|
|
235
|
+
|
|
236
|
+
return observer.observe(integration.api, call, extract)
|
|
237
|
+
|
|
238
|
+
return observed_entry_point
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _extract_observation(
|
|
242
|
+
model: Any,
|
|
243
|
+
error: BaseException | None,
|
|
244
|
+
parameters_before_solve: tuple[SolverParameter, ...],
|
|
245
|
+
counters_before_solve: dict[str, float | MissingReason] | None,
|
|
246
|
+
counters_after_solve: dict[str, float | MissingReason] | None,
|
|
247
|
+
) -> SolveObservation:
|
|
248
|
+
"""The SDK-free observation of one PySCIPOpt optimization call."""
|
|
249
|
+
if error is not None:
|
|
250
|
+
error_termination: Termination = (
|
|
251
|
+
Termination.INTERRUPTED
|
|
252
|
+
if isinstance(error, KeyboardInterrupt)
|
|
253
|
+
else Termination.ERROR
|
|
254
|
+
)
|
|
255
|
+
error_metrics: ReportedMetrics = ReportedMetrics(
|
|
256
|
+
has_objective=None,
|
|
257
|
+
sense=None,
|
|
258
|
+
objective_value=MissingReason.FAILED,
|
|
259
|
+
dual_bound=MissingReason.FAILED,
|
|
260
|
+
solver_time_seconds=MissingReason.FAILED,
|
|
261
|
+
statistics={},
|
|
262
|
+
native={},
|
|
263
|
+
)
|
|
264
|
+
error_metadata: ReportedMetadata = _read_metadata(
|
|
265
|
+
model, parameters_before_solve
|
|
266
|
+
)
|
|
267
|
+
error_outcome: ReportedOutcome = ReportedOutcome(
|
|
268
|
+
raw_status="unknown",
|
|
269
|
+
termination=error_termination,
|
|
270
|
+
has_solution=None,
|
|
271
|
+
)
|
|
272
|
+
return SolveObservation(
|
|
273
|
+
api="pyscipopt",
|
|
274
|
+
outcome=error_outcome,
|
|
275
|
+
metrics=error_metrics,
|
|
276
|
+
metadata=error_metadata,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
raw_status: str = _read_value(lambda: str(model.getStatus()), "unknown")
|
|
280
|
+
has_solution: bool | None = _read_value(lambda: model.getNSols() > 0, None)
|
|
281
|
+
termination: Termination = _STATUS_TERMINATIONS.get(
|
|
282
|
+
raw_status.lower(), Termination.UNKNOWN
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
has_objective: bool | None = _read_has_objective(model)
|
|
286
|
+
sense_name: str | MissingReason = _read_value(
|
|
287
|
+
lambda: str(model.getObjectiveSense()).lower(),
|
|
288
|
+
MissingReason.FAILED,
|
|
289
|
+
)
|
|
290
|
+
sense: ObjectiveSense | None = (
|
|
291
|
+
{
|
|
292
|
+
"minimize": ObjectiveSense.MINIMIZE,
|
|
293
|
+
"maximize": ObjectiveSense.MAXIMIZE,
|
|
294
|
+
}.get(sense_name)
|
|
295
|
+
if isinstance(sense_name, str)
|
|
296
|
+
else None
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
gap: float | MissingReason | None = None
|
|
300
|
+
if has_objective is False:
|
|
301
|
+
objective_value: float | MissingReason = MissingReason.UNDEFINED
|
|
302
|
+
dual_bound: float | MissingReason = MissingReason.UNDEFINED
|
|
303
|
+
else:
|
|
304
|
+
solver_infinity: float | MissingReason = _read_number(
|
|
305
|
+
lambda: float(model.infinity())
|
|
306
|
+
)
|
|
307
|
+
dual_bound = _read_scip_number(
|
|
308
|
+
lambda: float(model.getDualbound()),
|
|
309
|
+
solver_infinity,
|
|
310
|
+
)
|
|
311
|
+
if has_solution is False:
|
|
312
|
+
objective_value = MissingReason.NO_SOLUTION
|
|
313
|
+
else:
|
|
314
|
+
objective_value = _read_scip_number(
|
|
315
|
+
lambda: float(model.getObjVal()),
|
|
316
|
+
solver_infinity,
|
|
317
|
+
)
|
|
318
|
+
if has_solution is not False:
|
|
319
|
+
gap = _read_scip_number(
|
|
320
|
+
lambda: float(model.getGap()),
|
|
321
|
+
solver_infinity,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
assert counters_after_solve is not None
|
|
325
|
+
solver_time_seconds: float | MissingReason = _counter_difference(
|
|
326
|
+
counters_after_solve["solver_time_seconds"],
|
|
327
|
+
counters_before_solve,
|
|
328
|
+
"solver_time_seconds",
|
|
329
|
+
)
|
|
330
|
+
node_count: float | MissingReason = _counter_difference(
|
|
331
|
+
counters_after_solve["node_count"],
|
|
332
|
+
counters_before_solve,
|
|
333
|
+
"node_count",
|
|
334
|
+
)
|
|
335
|
+
statistics: dict[str, float | MissingReason] = {"node_count": node_count}
|
|
336
|
+
native: dict[str, float] = {}
|
|
337
|
+
|
|
338
|
+
_add_native_counter(
|
|
339
|
+
native,
|
|
340
|
+
"solution_count",
|
|
341
|
+
counters_after_solve["solution_count"],
|
|
342
|
+
counters_before_solve,
|
|
343
|
+
)
|
|
344
|
+
_add_native_counter(
|
|
345
|
+
native,
|
|
346
|
+
"solutions_found",
|
|
347
|
+
counters_after_solve["solutions_found"],
|
|
348
|
+
counters_before_solve,
|
|
349
|
+
)
|
|
350
|
+
_add_native_counter(
|
|
351
|
+
native,
|
|
352
|
+
"lp_count",
|
|
353
|
+
counters_after_solve["lp_count"],
|
|
354
|
+
counters_before_solve,
|
|
355
|
+
)
|
|
356
|
+
_add_native_counter(
|
|
357
|
+
native,
|
|
358
|
+
"lp_iterations",
|
|
359
|
+
counters_after_solve["lp_iterations"],
|
|
360
|
+
counters_before_solve,
|
|
361
|
+
)
|
|
362
|
+
_add_native_counter(
|
|
363
|
+
native,
|
|
364
|
+
"total_node_count",
|
|
365
|
+
counters_after_solve["total_node_count"],
|
|
366
|
+
counters_before_solve,
|
|
367
|
+
)
|
|
368
|
+
_add_native_counter(
|
|
369
|
+
native,
|
|
370
|
+
"presolving_time_seconds",
|
|
371
|
+
counters_after_solve["presolving_time_seconds"],
|
|
372
|
+
counters_before_solve,
|
|
373
|
+
)
|
|
374
|
+
if gap is not None and not isinstance(gap, MissingReason):
|
|
375
|
+
native["gap"] = gap
|
|
376
|
+
|
|
377
|
+
metrics: ReportedMetrics = ReportedMetrics(
|
|
378
|
+
has_objective=has_objective,
|
|
379
|
+
sense=sense,
|
|
380
|
+
objective_value=objective_value,
|
|
381
|
+
dual_bound=dual_bound,
|
|
382
|
+
solver_time_seconds=solver_time_seconds,
|
|
383
|
+
statistics=statistics,
|
|
384
|
+
native=native,
|
|
385
|
+
)
|
|
386
|
+
metadata: ReportedMetadata = _read_metadata(model, parameters_before_solve)
|
|
387
|
+
outcome: ReportedOutcome = ReportedOutcome(
|
|
388
|
+
raw_status=raw_status,
|
|
389
|
+
termination=termination,
|
|
390
|
+
has_solution=has_solution,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return SolveObservation(
|
|
394
|
+
api="pyscipopt", outcome=outcome, metrics=metrics, metadata=metadata
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _read_has_objective(model: Any) -> bool | None:
|
|
399
|
+
"""Whether the model has a nonzero objective function."""
|
|
400
|
+
objective_terms_present: bool | MissingReason = _read_value(
|
|
401
|
+
lambda: bool(model.getObjective().terms),
|
|
402
|
+
MissingReason.FAILED,
|
|
403
|
+
)
|
|
404
|
+
objective_offset: float | MissingReason = _read_value(
|
|
405
|
+
lambda: float(model.getObjoffset()),
|
|
406
|
+
MissingReason.FAILED,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if objective_terms_present is True:
|
|
410
|
+
return True
|
|
411
|
+
if isinstance(objective_offset, float) and objective_offset != 0.0:
|
|
412
|
+
return True
|
|
413
|
+
if objective_terms_present is False and objective_offset == 0.0:
|
|
414
|
+
return False
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _read_metadata(
|
|
419
|
+
model: Any,
|
|
420
|
+
parameters: tuple[SolverParameter, ...],
|
|
421
|
+
) -> ReportedMetadata:
|
|
422
|
+
"""The solver and problem metadata reported by PySCIPOpt."""
|
|
423
|
+
api_version: str | None = _read_value(
|
|
424
|
+
lambda: str(
|
|
425
|
+
pyscipopt.__version__ # pyright: ignore[reportPrivateImportUsage] -- Exposed at runtime.
|
|
426
|
+
),
|
|
427
|
+
None,
|
|
428
|
+
)
|
|
429
|
+
major_version: int | None = _read_value(lambda: int(model.getMajorVersion()), None)
|
|
430
|
+
minor_version: int | None = _read_value(lambda: int(model.getMinorVersion()), None)
|
|
431
|
+
technical_version: int | None = _read_value(
|
|
432
|
+
lambda: int(model.getTechVersion()), None
|
|
433
|
+
)
|
|
434
|
+
solver_version: str | None = None
|
|
435
|
+
|
|
436
|
+
if (
|
|
437
|
+
major_version is not None
|
|
438
|
+
and minor_version is not None
|
|
439
|
+
and technical_version is not None
|
|
440
|
+
):
|
|
441
|
+
solver_version = f"{major_version}.{minor_version}.{technical_version}"
|
|
442
|
+
|
|
443
|
+
problem: dict[str, int] = {}
|
|
444
|
+
variable_count: int | None = _read_value(
|
|
445
|
+
lambda: int(model.getNVars(transformed=False)), None
|
|
446
|
+
)
|
|
447
|
+
constraint_count: int | None = _read_value(
|
|
448
|
+
lambda: int(model.getNConss(transformed=False)), None
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if variable_count is not None:
|
|
452
|
+
problem["num_variables"] = variable_count
|
|
453
|
+
if constraint_count is not None:
|
|
454
|
+
problem["num_constraints"] = constraint_count
|
|
455
|
+
|
|
456
|
+
variables: list[Any] | MissingReason = _read_value(
|
|
457
|
+
lambda: model.getVars(transformed=False),
|
|
458
|
+
MissingReason.FAILED,
|
|
459
|
+
)
|
|
460
|
+
if not isinstance(variables, MissingReason):
|
|
461
|
+
integer_variable_count: int = 0
|
|
462
|
+
integer_variable_count_available: bool = True
|
|
463
|
+
for variable in variables:
|
|
464
|
+
variable_type: str | MissingReason = _read_value(
|
|
465
|
+
lambda variable=variable: str(variable.vtype()).upper(),
|
|
466
|
+
MissingReason.FAILED,
|
|
467
|
+
)
|
|
468
|
+
if isinstance(variable_type, MissingReason):
|
|
469
|
+
integer_variable_count_available = False
|
|
470
|
+
break
|
|
471
|
+
if variable_type in {"BINARY", "INTEGER", "IMPLINT"}:
|
|
472
|
+
integer_variable_count += 1
|
|
473
|
+
if integer_variable_count_available:
|
|
474
|
+
problem["num_integer_variables"] = integer_variable_count
|
|
475
|
+
|
|
476
|
+
return ReportedMetadata(
|
|
477
|
+
solver="scip",
|
|
478
|
+
api_version=api_version,
|
|
479
|
+
solver_version=solver_version,
|
|
480
|
+
problem=problem,
|
|
481
|
+
parameters=parameters,
|
|
482
|
+
wall_time_includes_problem_build=False,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _read_default_parameters(
|
|
487
|
+
model_class: type[Any],
|
|
488
|
+
) -> dict[str, bool | int | float | str] | None:
|
|
489
|
+
"""The PySCIPOpt parameter values of a new model."""
|
|
490
|
+
defaults_model: Any | MissingReason = _read_value(
|
|
491
|
+
lambda: model_class(),
|
|
492
|
+
MissingReason.FAILED,
|
|
493
|
+
)
|
|
494
|
+
if isinstance(defaults_model, MissingReason):
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
default_values: dict[str, Any] | MissingReason
|
|
498
|
+
try:
|
|
499
|
+
default_values = _read_value(
|
|
500
|
+
lambda: defaults_model.getParams(),
|
|
501
|
+
MissingReason.FAILED,
|
|
502
|
+
)
|
|
503
|
+
finally:
|
|
504
|
+
_read_value(lambda: defaults_model.free(), None)
|
|
505
|
+
|
|
506
|
+
if isinstance(default_values, MissingReason) or not isinstance(
|
|
507
|
+
default_values, dict
|
|
508
|
+
):
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
default_parameters: dict[str, bool | int | float | str] = {}
|
|
512
|
+
for parameter_name, parameter_value in default_values.items():
|
|
513
|
+
if isinstance(parameter_name, str) and isinstance(
|
|
514
|
+
parameter_value,
|
|
515
|
+
(bool, int, float, str),
|
|
516
|
+
):
|
|
517
|
+
default_parameters[parameter_name] = parameter_value
|
|
518
|
+
return default_parameters
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _read_parameters(
|
|
522
|
+
model: Any,
|
|
523
|
+
default_parameters: dict[str, bool | int | float | str] | None,
|
|
524
|
+
) -> tuple[SolverParameter, ...]:
|
|
525
|
+
"""The PySCIPOpt parameters that differ from their defaults."""
|
|
526
|
+
if default_parameters is None:
|
|
527
|
+
return ()
|
|
528
|
+
|
|
529
|
+
parameters: list[SolverParameter] = []
|
|
530
|
+
for parameter_name, default_value in default_parameters.items():
|
|
531
|
+
parameter_value: Any | MissingReason = _read_value(
|
|
532
|
+
lambda parameter_name=parameter_name: model.getParam(parameter_name),
|
|
533
|
+
MissingReason.FAILED,
|
|
534
|
+
)
|
|
535
|
+
if isinstance(parameter_value, MissingReason):
|
|
536
|
+
continue
|
|
537
|
+
if not isinstance(parameter_value, (bool, int, float, str)):
|
|
538
|
+
continue
|
|
539
|
+
if parameter_value == default_value:
|
|
540
|
+
continue
|
|
541
|
+
parameters.append(
|
|
542
|
+
SolverParameter(
|
|
543
|
+
name=parameter_name,
|
|
544
|
+
value=parameter_value,
|
|
545
|
+
origin=ParameterOrigin.EXPLICIT,
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return tuple(parameters)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _read_solve_counters(model: Any) -> dict[str, float | MissingReason]:
|
|
553
|
+
"""The current values of PySCIPOpt counters and timers."""
|
|
554
|
+
return {
|
|
555
|
+
"solver_time_seconds": _read_number(lambda: float(model.getSolvingTime())),
|
|
556
|
+
"node_count": _read_number(lambda: float(model.getNNodes())),
|
|
557
|
+
"solution_count": _read_number(lambda: float(model.getNSols())),
|
|
558
|
+
"solutions_found": _read_number(lambda: float(model.getNSolsFound())),
|
|
559
|
+
"lp_count": _read_number(lambda: float(model.getNLPs())),
|
|
560
|
+
"lp_iterations": _read_number(lambda: float(model.getNLPIterations())),
|
|
561
|
+
"total_node_count": _read_number(lambda: float(model.getNTotalNodes())),
|
|
562
|
+
"presolving_time_seconds": _read_number(
|
|
563
|
+
lambda: float(model.getPresolvingTime())
|
|
564
|
+
),
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _unavailable_solve_counters() -> dict[str, float | MissingReason]:
|
|
569
|
+
"""Unavailable PySCIPOpt counters for a per-solve baseline."""
|
|
570
|
+
return {
|
|
571
|
+
"solver_time_seconds": MissingReason.FAILED,
|
|
572
|
+
"node_count": MissingReason.FAILED,
|
|
573
|
+
"solution_count": MissingReason.FAILED,
|
|
574
|
+
"solutions_found": MissingReason.FAILED,
|
|
575
|
+
"lp_count": MissingReason.FAILED,
|
|
576
|
+
"lp_iterations": MissingReason.FAILED,
|
|
577
|
+
"total_node_count": MissingReason.FAILED,
|
|
578
|
+
"presolving_time_seconds": MissingReason.FAILED,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _add_native_counter(
|
|
583
|
+
native: dict[str, float],
|
|
584
|
+
metric_name: str,
|
|
585
|
+
current_value: float | MissingReason,
|
|
586
|
+
counters_before_solve: dict[str, float | MissingReason] | None,
|
|
587
|
+
) -> None:
|
|
588
|
+
"""One available PySCIPOpt counter difference in native metrics."""
|
|
589
|
+
value: float | MissingReason = _counter_difference(
|
|
590
|
+
current_value,
|
|
591
|
+
counters_before_solve,
|
|
592
|
+
metric_name,
|
|
593
|
+
)
|
|
594
|
+
if isinstance(value, MissingReason):
|
|
595
|
+
return
|
|
596
|
+
native[metric_name] = value
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _read_number(
|
|
600
|
+
reader: Callable[[], float],
|
|
601
|
+
) -> float | MissingReason:
|
|
602
|
+
"""A guarded PySCIPOpt numeric value."""
|
|
603
|
+
return _read_value(reader, MissingReason.FAILED)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _counter_difference(
|
|
607
|
+
current_value: float | MissingReason,
|
|
608
|
+
counters_before_solve: dict[str, float | MissingReason] | None,
|
|
609
|
+
counter_name: str,
|
|
610
|
+
) -> float | MissingReason:
|
|
611
|
+
"""The nonnegative difference between two PySCIPOpt counter values."""
|
|
612
|
+
if isinstance(current_value, MissingReason):
|
|
613
|
+
return current_value
|
|
614
|
+
if counters_before_solve is None:
|
|
615
|
+
if counter_name == "solution_count":
|
|
616
|
+
return MissingReason.UNSUPPORTED
|
|
617
|
+
return current_value
|
|
618
|
+
previous_value: float | MissingReason = counters_before_solve.get(
|
|
619
|
+
counter_name,
|
|
620
|
+
MissingReason.FAILED,
|
|
621
|
+
)
|
|
622
|
+
if isinstance(previous_value, MissingReason):
|
|
623
|
+
return previous_value
|
|
624
|
+
difference: float = current_value - previous_value
|
|
625
|
+
if difference < 0.0:
|
|
626
|
+
return MissingReason.FAILED
|
|
627
|
+
return difference
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _read_scip_number(
|
|
631
|
+
reader: Callable[[], float],
|
|
632
|
+
solver_infinity: float | MissingReason,
|
|
633
|
+
) -> float | MissingReason:
|
|
634
|
+
"""A PySCIPOpt numeric value with its infinity sentinel normalized."""
|
|
635
|
+
value: float | MissingReason = _read_number(reader)
|
|
636
|
+
if isinstance(value, MissingReason):
|
|
637
|
+
return value
|
|
638
|
+
if math.isinf(value):
|
|
639
|
+
return value
|
|
640
|
+
if isinstance(solver_infinity, MissingReason) or solver_infinity <= 0.0:
|
|
641
|
+
return MissingReason.FAILED
|
|
642
|
+
if value >= solver_infinity:
|
|
643
|
+
return math.inf
|
|
644
|
+
if value <= -solver_infinity:
|
|
645
|
+
return -math.inf
|
|
646
|
+
return value
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _read_value[Value](
|
|
650
|
+
reader: Callable[[], Value],
|
|
651
|
+
unavailable: Value,
|
|
652
|
+
) -> Value:
|
|
653
|
+
"""A PySCIPOpt value or its fallback after a supported read error."""
|
|
654
|
+
try:
|
|
655
|
+
return reader()
|
|
656
|
+
except Exception: # noqa: BLE001
|
|
657
|
+
return unavailable
|