pyepo 2.2.0__tar.gz → 2.2.2__tar.gz
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.
- {pyepo-2.2.0 → pyepo-2.2.2}/PKG-INFO +4 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/data/dataset.py +62 -32
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/data/knapsack.py +2 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/data/shortestpath.py +2 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/data/tsp.py +2 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/dsl/compiled.py +29 -7
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/dsl/expression.py +106 -32
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/dsl/objective.py +11 -4
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/dsl/problem.py +41 -6
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/__init__.py +36 -18
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/blackbox.py +7 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/__init__.py +8 -8
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/abcmodule.py +17 -11
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/blackbox.py +54 -49
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/cave.py +15 -6
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/contrastive.py +3 -3
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/perturbed.py +155 -149
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/rank.py +8 -8
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/regularized.py +23 -14
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/surrogate.py +6 -5
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/jax/utils.py +79 -44
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/perturbed.py +22 -7
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/regularized.py +19 -5
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/surrogate.py +5 -5
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/utils.py +29 -16
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/metric/metrics.py +56 -35
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/metric/mse.py +7 -5
- pyepo-2.2.2/pyepo/metric/regret.py +216 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/metric/unambregret.py +31 -24
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/compile.py +14 -5
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/coptmodel.py +14 -7
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/knapsack.py +8 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/portfolio.py +4 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/shortestpath.py +8 -6
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/tsp.py +7 -3
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/vrp.py +11 -4
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/compile.py +9 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/grbmodel.py +35 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/knapsack.py +3 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/shortestpath.py +4 -6
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/tsp.py +23 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/vrp.py +50 -6
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/mpax/compile.py +44 -11
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/mpax/mpaxmodel.py +14 -3
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/compile.py +26 -8
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/knapsack.py +5 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/omomodel.py +12 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/portfolio.py +1 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/vrp.py +4 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/opt.py +6 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/ort/compile.py +14 -7
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/ort/knapsack.py +5 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/ort/ortmodel.py +9 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/predefined.py +6 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/utils.py +13 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo.egg-info/PKG-INFO +4 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo.egg-info/requires.txt +4 -1
- {pyepo-2.2.0 → pyepo-2.2.2}/pyproject.toml +3 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_15_dsl.py +168 -4
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_20_data_gen.py +12 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_30_model.py +133 -15
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_40_dataset.py +11 -10
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_50_func.py +240 -38
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_55_jax.py +256 -96
- pyepo-2.2.2/tests/test_60_metric.py +558 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_70_twostage.py +1 -1
- pyepo-2.2.0/pyepo/metric/regret.py +0 -119
- pyepo-2.2.0/tests/test_60_metric.py +0 -272
- {pyepo-2.2.0 → pyepo-2.2.2}/LICENSE +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/README.md +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/EPO.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/data/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/data/portfolio.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/dsl/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/abcmodule.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/cave.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/contrastive.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/func/rank.py +2 -2
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/metric/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/bases.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/copt/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/grb/portfolio.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/mpax/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/mpax/knapsack.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/mpax/shortestpath.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/shortestpath.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/omo/tsp.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/ort/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/ort/ortcpmodel.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/model/ort/shortestpath.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/py.typed +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/twostage/__init__.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/twostage/autosklearnpred.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/twostage/sklearnpred.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo/utils.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo.egg-info/SOURCES.txt +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo.egg-info/dependency_links.txt +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/pyepo.egg-info/top_level.txt +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/setup.cfg +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_00_constants.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_10_utils.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_80_integration.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_85_backend_pipeline.py +0 -0
- {pyepo-2.2.0 → pyepo-2.2.2}/tests/test_90_cuda.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyepo
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.2
|
|
4
4
|
Summary: PyTorch-based End-to-End Predict-then-Optimize Tool
|
|
5
5
|
Author-email: Bo Tang <bolucas.tang@mail.utoronto.ca>
|
|
6
6
|
License: MIT
|
|
@@ -31,7 +31,6 @@ Requires-Dist: pathos
|
|
|
31
31
|
Requires-Dist: tqdm
|
|
32
32
|
Requires-Dist: scikit_learn
|
|
33
33
|
Requires-Dist: torch>=1.13.1
|
|
34
|
-
Requires-Dist: clarabel
|
|
35
34
|
Provides-Extra: pyomo
|
|
36
35
|
Requires-Dist: pyomo>=6.1.2; extra == "pyomo"
|
|
37
36
|
Provides-Extra: gurobi
|
|
@@ -40,6 +39,8 @@ Provides-Extra: copt
|
|
|
40
39
|
Requires-Dist: coptpy; extra == "copt"
|
|
41
40
|
Provides-Extra: ortools
|
|
42
41
|
Requires-Dist: ortools>=9.6; extra == "ortools"
|
|
42
|
+
Provides-Extra: cave
|
|
43
|
+
Requires-Dist: clarabel; extra == "cave"
|
|
43
44
|
Provides-Extra: mpax
|
|
44
45
|
Requires-Dist: mpax; extra == "mpax"
|
|
45
46
|
Requires-Dist: jax>=0.4.1; extra == "mpax"
|
|
@@ -51,6 +52,7 @@ Provides-Extra: autosklearn
|
|
|
51
52
|
Requires-Dist: auto-sklearn; extra == "autosklearn"
|
|
52
53
|
Requires-Dist: configspace; extra == "autosklearn"
|
|
53
54
|
Provides-Extra: all
|
|
55
|
+
Requires-Dist: clarabel; extra == "all"
|
|
54
56
|
Requires-Dist: pyomo>=6.1.2; extra == "all"
|
|
55
57
|
Requires-Dist: gurobipy>=9.1.2; extra == "all"
|
|
56
58
|
Requires-Dist: coptpy; extra == "all"
|
|
@@ -64,6 +64,11 @@ class optDataset(Dataset):
|
|
|
64
64
|
"""
|
|
65
65
|
if not isinstance(model, optModel):
|
|
66
66
|
raise TypeError("arg model is not an optModel")
|
|
67
|
+
if len(feats) != len(costs):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"feats and costs must have the same number of instances: "
|
|
70
|
+
f"{len(feats)} vs {len(costs)}."
|
|
71
|
+
)
|
|
67
72
|
self.model = model
|
|
68
73
|
# data
|
|
69
74
|
self.feats = feats
|
|
@@ -86,15 +91,10 @@ class optDataset(Dataset):
|
|
|
86
91
|
objs = []
|
|
87
92
|
logger.info("Optimizing for optDataset...")
|
|
88
93
|
for c in tqdm(self.costs):
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
sol = sol.detach().cpu().numpy()
|
|
94
|
-
except Exception as e:
|
|
95
|
-
raise ValueError(
|
|
96
|
-
"For optModel, the method 'solve' should return solution vector and objective value."
|
|
97
|
-
) from e
|
|
94
|
+
sol, obj = self._solve(c)
|
|
95
|
+
# to numpy
|
|
96
|
+
if isinstance(sol, torch.Tensor):
|
|
97
|
+
sol = sol.detach().cpu().numpy()
|
|
98
98
|
sols.append(np.asarray(sol))
|
|
99
99
|
objs.append(obj)
|
|
100
100
|
return np.stack(sols), np.asarray(objs).reshape(-1, 1)
|
|
@@ -107,15 +107,19 @@ class optDataset(Dataset):
|
|
|
107
107
|
from pyepo.model.mpax.mpaxmodel import _warn_if_not_optimal
|
|
108
108
|
|
|
109
109
|
model = cast("_optMpaxModelT", self.model)
|
|
110
|
-
model.
|
|
110
|
+
model._setFullObj(model._fullCost(self.costs))
|
|
111
111
|
sols, objs, status = model.batch_optimize(model.c)
|
|
112
112
|
_warn_if_not_optimal(status)
|
|
113
113
|
# writable copy; torch.as_tensor warns on JAX read-only buffers
|
|
114
114
|
sols_np = np.array(sols, dtype=np.float32)
|
|
115
115
|
objs_np = np.array(objs, dtype=np.float32)
|
|
116
|
-
# jitted_solve returns c·sol where
|
|
116
|
+
# jitted_solve returns c·sol where the objective write already negated c for MAX
|
|
117
117
|
if self.model.modelSense == EPO.MAXIMIZE:
|
|
118
118
|
objs_np = -objs_np
|
|
119
|
+
# compiled DSL problems carry bare objective constants outside the solver model
|
|
120
|
+
problem = getattr(self.model, "problem", None)
|
|
121
|
+
if problem is not None:
|
|
122
|
+
objs_np += problem.obj_offset
|
|
119
123
|
return sols_np, objs_np.reshape(-1, 1)
|
|
120
124
|
|
|
121
125
|
def _solve(
|
|
@@ -131,7 +135,7 @@ class optDataset(Dataset):
|
|
|
131
135
|
Returns:
|
|
132
136
|
tuple: optimal solution (np.ndarray) and objective value (float)
|
|
133
137
|
"""
|
|
134
|
-
self.model.
|
|
138
|
+
self.model._setFullObj(self.model._fullCost(cost))
|
|
135
139
|
sol, obj = self.model.solve()
|
|
136
140
|
return sol, obj
|
|
137
141
|
|
|
@@ -207,6 +211,11 @@ class optDatasetKNN(optDataset):
|
|
|
207
211
|
"""
|
|
208
212
|
if not isinstance(model, optModel):
|
|
209
213
|
raise TypeError("arg model is not an optModel")
|
|
214
|
+
if len(feats) != len(costs):
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"feats and costs must have the same number of instances: "
|
|
217
|
+
f"{len(feats)} vs {len(costs)}."
|
|
218
|
+
)
|
|
210
219
|
self.model = model
|
|
211
220
|
# at most num_data-1 neighbours exist (self excluded), so k must stay below it
|
|
212
221
|
num_data = len(feats)
|
|
@@ -241,14 +250,9 @@ class optDatasetKNN(optDataset):
|
|
|
241
250
|
sol_knn = np.zeros((self.costs.shape[1], self.k))
|
|
242
251
|
obj_knn = np.zeros(self.k)
|
|
243
252
|
for i, c in enumerate(c_knn.T):
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
sol_i = sol_i.detach().cpu().numpy()
|
|
248
|
-
except Exception as e:
|
|
249
|
-
raise ValueError(
|
|
250
|
-
"For optModel, the method 'solve' should return solution vector and objective value."
|
|
251
|
-
) from e
|
|
253
|
+
sol_i, obj_i = self._solve(c)
|
|
254
|
+
if isinstance(sol_i, torch.Tensor):
|
|
255
|
+
sol_i = sol_i.detach().cpu().numpy()
|
|
252
256
|
sol_knn[:, i] = sol_i
|
|
253
257
|
obj_knn[i] = obj_i
|
|
254
258
|
# get average
|
|
@@ -264,6 +268,11 @@ class optDatasetKNN(optDataset):
|
|
|
264
268
|
"""
|
|
265
269
|
A method to get kNN costs
|
|
266
270
|
"""
|
|
271
|
+
# scipy needs host numpy arrays
|
|
272
|
+
if isinstance(self.feats, torch.Tensor):
|
|
273
|
+
self.feats = self.feats.detach().cpu().numpy()
|
|
274
|
+
if isinstance(self.costs, torch.Tensor):
|
|
275
|
+
self.costs = self.costs.detach().cpu().numpy()
|
|
267
276
|
# calculate distances between features
|
|
268
277
|
distances = distance.cdist(self.feats, self.feats, "euclidean")
|
|
269
278
|
# exclude self (diagonal) to get true nearest neighbours
|
|
@@ -281,9 +290,9 @@ class optDatasetConstrs(optDataset):
|
|
|
281
290
|
"""
|
|
282
291
|
PyTorch ``Dataset`` for the CaVE cone-aligned loss.
|
|
283
292
|
|
|
284
|
-
Stores features and cost coefficients, solves each instance
|
|
285
|
-
|
|
286
|
-
|
|
293
|
+
Stores features and cost coefficients, solves each instance, and extracts
|
|
294
|
+
the **normals of the binding constraints at the optimal vertex** in
|
|
295
|
+
canonical ``<=`` orientation.
|
|
287
296
|
These normals span the polyhedral cone onto which ``coneAlignedCosine``
|
|
288
297
|
projects the predicted cost vector during training.
|
|
289
298
|
|
|
@@ -327,6 +336,11 @@ class optDatasetConstrs(optDataset):
|
|
|
327
336
|
"""
|
|
328
337
|
if not isinstance(model, optModel):
|
|
329
338
|
raise TypeError("arg model is not an optModel")
|
|
339
|
+
if len(feats) != len(costs):
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"feats and costs must have the same number of instances: "
|
|
342
|
+
f"{len(feats)} vs {len(costs)}."
|
|
343
|
+
)
|
|
330
344
|
self.model = model
|
|
331
345
|
self.skip_infeas = skip_infeas
|
|
332
346
|
# data
|
|
@@ -355,11 +369,16 @@ class optDatasetConstrs(optDataset):
|
|
|
355
369
|
ctrs: list[np.ndarray] = []
|
|
356
370
|
valid: list[int] = []
|
|
357
371
|
logger.info("Optimizing for optDatasetConstrs...")
|
|
372
|
+
model = self.model
|
|
358
373
|
for i, c in enumerate(tqdm(self.costs)):
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
374
|
+
model._setFullObj(model._fullCost(c))
|
|
375
|
+
try:
|
|
376
|
+
sol, obj = model.solve()
|
|
377
|
+
except RuntimeError as e:
|
|
378
|
+
if self.skip_infeas:
|
|
379
|
+
logger.warning("Instance %d had no solution, skipping: %s", i, e)
|
|
380
|
+
continue
|
|
381
|
+
raise
|
|
363
382
|
# infeasibility check
|
|
364
383
|
if model._model.Status != GRB.OPTIMAL:
|
|
365
384
|
if self.skip_infeas:
|
|
@@ -375,7 +394,13 @@ class optDatasetConstrs(optDataset):
|
|
|
375
394
|
)
|
|
376
395
|
# binary-vertex check: CaVE is defined for binary linear programs
|
|
377
396
|
sol_arr = np.asarray(sol, dtype=np.float64)
|
|
378
|
-
|
|
397
|
+
is_binary = np.all(
|
|
398
|
+
np.isclose(sol_arr, 0.0, atol=1e-5) | np.isclose(sol_arr, 1.0, atol=1e-5)
|
|
399
|
+
)
|
|
400
|
+
if not is_binary:
|
|
401
|
+
if self.skip_infeas:
|
|
402
|
+
logger.warning("Instance %d optimal vertex is not binary, skipping.", i)
|
|
403
|
+
continue
|
|
379
404
|
raise ValueError(
|
|
380
405
|
f"Instance {i} optimal vertex is not binary; "
|
|
381
406
|
"CaVE requires binary linear programs."
|
|
@@ -384,6 +409,8 @@ class optDatasetConstrs(optDataset):
|
|
|
384
409
|
objs.append([float(obj)])
|
|
385
410
|
ctrs.append(_extract_tight_normals(model, sol_arr))
|
|
386
411
|
valid.append(i)
|
|
412
|
+
if not valid:
|
|
413
|
+
raise ValueError("No valid instances (all skipped or empty input).")
|
|
387
414
|
return np.stack(sols), np.asarray(objs), ctrs, valid
|
|
388
415
|
|
|
389
416
|
def __len__(self) -> int:
|
|
@@ -455,11 +482,14 @@ def _extract_tight_normals(
|
|
|
455
482
|
senses_arr = np.asarray(grb.getAttr("Sense", constrs))
|
|
456
483
|
tight_mask = np.abs(slacks) < tol
|
|
457
484
|
if tight_mask.any():
|
|
458
|
-
#
|
|
459
|
-
|
|
460
|
-
|
|
485
|
+
# cost-column slice cached across solves; the row count keys invalidation
|
|
486
|
+
cache = getattr(grb, "_cave_A_cost", None)
|
|
487
|
+
if cache is None or cache[0] != grb.NumConstrs:
|
|
488
|
+
cost_col_idx = np.asarray([v.index for v in cost_vars])
|
|
489
|
+
cache = (grb.NumConstrs, grb.getA().tocsr()[:, cost_col_idx])
|
|
490
|
+
grb._cave_A_cost = cache
|
|
461
491
|
# extract all tight rows in a single sparse-to-dense conversion
|
|
462
|
-
A_tight =
|
|
492
|
+
A_tight = cache[1][tight_mask].toarray()
|
|
463
493
|
tight_senses = senses_arr[tight_mask]
|
|
464
494
|
is_le = tight_senses == GRB.LESS_EQUAL
|
|
465
495
|
is_ge = tight_senses == GRB.GREATER_EQUAL
|
|
@@ -44,6 +44,8 @@ def genData(
|
|
|
44
44
|
raise ValueError(f"deg = {deg} should be int.")
|
|
45
45
|
if deg <= 0:
|
|
46
46
|
raise ValueError(f"deg = {deg} should be positive.")
|
|
47
|
+
if noise_width < 0:
|
|
48
|
+
raise ValueError(f"noise_width = {noise_width} should be non-negative.")
|
|
47
49
|
# set seed
|
|
48
50
|
rnd = np.random.RandomState(seed)
|
|
49
51
|
# number of data points
|
|
@@ -41,6 +41,8 @@ def genData(
|
|
|
41
41
|
raise ValueError(f"deg = {deg} should be int.")
|
|
42
42
|
if deg <= 0:
|
|
43
43
|
raise ValueError(f"deg = {deg} should be positive.")
|
|
44
|
+
if noise_width < 0:
|
|
45
|
+
raise ValueError(f"noise_width = {noise_width} should be non-negative.")
|
|
44
46
|
# set seed
|
|
45
47
|
rnd = np.random.RandomState(seed)
|
|
46
48
|
# number of data points
|
|
@@ -43,6 +43,8 @@ def genData(
|
|
|
43
43
|
raise ValueError(f"deg = {deg} should be int.")
|
|
44
44
|
if deg <= 0:
|
|
45
45
|
raise ValueError(f"deg = {deg} should be positive.")
|
|
46
|
+
if noise_width < 0:
|
|
47
|
+
raise ValueError(f"noise_width = {noise_width} should be non-negative.")
|
|
46
48
|
# set seed
|
|
47
49
|
rnd = np.random.RandomState(seed)
|
|
48
50
|
# number of data points
|
|
@@ -42,13 +42,22 @@ class compiledBase(optModel):
|
|
|
42
42
|
|
|
43
43
|
def setObj(self, c):
|
|
44
44
|
"""Set the objective from a predicted cost of length ``num_cost``, scattered onto the known fixed costs."""
|
|
45
|
-
# scatter onto fixed costs
|
|
46
45
|
prob = self.problem
|
|
47
46
|
coef = costToNumpy(c)
|
|
48
|
-
|
|
47
|
+
# scatter onto fixed costs; an unambiguous full-length vector passes through
|
|
48
|
+
if coef.shape[-1] == prob.num_cost:
|
|
49
49
|
full = prob.fixed_cost.copy()
|
|
50
50
|
full[prob.c_pred_index] += coef
|
|
51
51
|
coef = full
|
|
52
|
+
elif coef.shape[-1] != prob.num_vars:
|
|
53
|
+
raise ValueError("Size of cost vector does not match number of cost variables.")
|
|
54
|
+
self._write_obj(coef)
|
|
55
|
+
|
|
56
|
+
def _setFullObj(self, c):
|
|
57
|
+
"""Set the objective from full-space coefficients (length ``num_vars``), bypassing the predicted-cost scatter."""
|
|
58
|
+
coef = costToNumpy(c)
|
|
59
|
+
if coef.shape[-1] != self.problem.num_vars:
|
|
60
|
+
raise ValueError("Size of cost vector does not match number of variables.")
|
|
52
61
|
self._write_obj(coef)
|
|
53
62
|
|
|
54
63
|
def _fullCost(self, pred_cost):
|
|
@@ -57,8 +66,12 @@ class compiledBase(optModel):
|
|
|
57
66
|
idx = prob.c_pred_index
|
|
58
67
|
if isinstance(pred_cost, torch.Tensor):
|
|
59
68
|
index = torch.as_tensor(idx, dtype=torch.long, device=pred_cost.device)
|
|
60
|
-
scattered = pred_cost.new_zeros((*pred_cost.shape[:-1], prob.num_vars)).index_add(
|
|
61
|
-
|
|
69
|
+
scattered = pred_cost.new_zeros((*pred_cost.shape[:-1], prob.num_vars)).index_add(
|
|
70
|
+
-1, index, pred_cost
|
|
71
|
+
)
|
|
72
|
+
return scattered + torch.as_tensor(
|
|
73
|
+
prob.fixed_cost, dtype=pred_cost.dtype, device=pred_cost.device
|
|
74
|
+
)
|
|
62
75
|
arr = np.asarray(pred_cost, dtype=float)
|
|
63
76
|
full = np.broadcast_to(prob.fixed_cost, (*arr.shape[:-1], prob.num_vars)).copy()
|
|
64
77
|
full[..., idx] += arr
|
|
@@ -67,17 +80,26 @@ class compiledBase(optModel):
|
|
|
67
80
|
def solve(self):
|
|
68
81
|
"""Solve and return the full decision-vector solution (length ``num_vars``) with its objective value."""
|
|
69
82
|
sol, obj = self._read_sol()
|
|
70
|
-
|
|
83
|
+
# bare objective constants live outside the solver model
|
|
84
|
+
return np.asarray(sol), obj + self.problem.obj_offset
|
|
71
85
|
|
|
72
86
|
def addConstr(self, coefs, rhs):
|
|
73
87
|
# add a cut coefs @ x <= rhs over the full variable vector
|
|
74
|
-
|
|
88
|
+
coefs = np.asarray(coefs, dtype=float).reshape(-1)
|
|
89
|
+
new_model = self._add_cut(coefs, rhs)
|
|
90
|
+
# track for replay on relax
|
|
91
|
+
new_model._extra_constrs = [*self._extra_constrs, (coefs, float(rhs))]
|
|
92
|
+
return new_model
|
|
75
93
|
|
|
76
94
|
def relax(self):
|
|
77
95
|
# recompile the relaxed problem, preserving backend kwargs
|
|
78
96
|
kwargs = getArgs(self)
|
|
79
97
|
kwargs["problem"] = self.problem.relax()
|
|
80
|
-
|
|
98
|
+
model_rel = type(self)(**kwargs)
|
|
99
|
+
# replay user cuts on the relaxation
|
|
100
|
+
for coefs, rhs in self._extra_constrs:
|
|
101
|
+
model_rel = model_rel.addConstr(coefs, rhs)
|
|
102
|
+
return model_rel
|
|
81
103
|
|
|
82
104
|
def _apply_params(self):
|
|
83
105
|
# push self.params to the solver
|
|
@@ -39,7 +39,7 @@ class Variable:
|
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
def __init__(self, shape, *, vtype=EPO.CONTINUOUS, lb=None, ub=None, name=None):
|
|
42
|
-
self.shape =
|
|
42
|
+
self.shape = tuple(int(s) for s in np.atleast_1d(shape))
|
|
43
43
|
self.size = int(np.prod(self.shape)) if self.shape else 1
|
|
44
44
|
# per-entry type: a scalar EPO.VarType broadcast, or a per-entry array
|
|
45
45
|
self.vtype = np.broadcast_to(np.asarray(vtype, dtype=object), self.shape).reshape(-1).copy()
|
|
@@ -86,6 +86,9 @@ class Variable:
|
|
|
86
86
|
def __rmul__(self, o):
|
|
87
87
|
return self._to_affine() * o
|
|
88
88
|
|
|
89
|
+
def __truediv__(self, o):
|
|
90
|
+
return self._to_affine() / o
|
|
91
|
+
|
|
89
92
|
def __matmul__(self, o):
|
|
90
93
|
return self._to_affine() @ o
|
|
91
94
|
|
|
@@ -150,7 +153,11 @@ class Affine:
|
|
|
150
153
|
self._add_block(blocks, v, b)
|
|
151
154
|
return Affine(blocks, self.const + o.const, self.shape)
|
|
152
155
|
if _is_num(o):
|
|
153
|
-
|
|
156
|
+
# broadcast the constant over the logical shape; a flat full-length vector is kept as-is
|
|
157
|
+
a = np.asarray(o, dtype=float)
|
|
158
|
+
if not (a.ndim == 1 and a.size == self.size):
|
|
159
|
+
a = np.broadcast_to(a, self.shape or (self.size,))
|
|
160
|
+
return Affine(self.blocks, self.const + a.reshape(-1), self.shape)
|
|
154
161
|
return NotImplemented
|
|
155
162
|
|
|
156
163
|
def __radd__(self, o):
|
|
@@ -186,6 +193,12 @@ class Affine:
|
|
|
186
193
|
# reflected scale
|
|
187
194
|
return self * o
|
|
188
195
|
|
|
196
|
+
def __truediv__(self, o):
|
|
197
|
+
# elementwise divide by a scalar or shape-broadcast array
|
|
198
|
+
if _is_num(o):
|
|
199
|
+
return self * (1.0 / np.asarray(o, dtype=float))
|
|
200
|
+
return NotImplemented
|
|
201
|
+
|
|
189
202
|
def __matmul__(self, o):
|
|
190
203
|
# Affine @ (ndarray) -> Affine ; Affine @ (Variable | Affine) -> Quadratic
|
|
191
204
|
if isinstance(o, Variable):
|
|
@@ -216,22 +229,29 @@ class Affine:
|
|
|
216
229
|
# reduce along axis (or fully) via a 0/1 summation matrix
|
|
217
230
|
if axis is None:
|
|
218
231
|
return self._row_op(np.ones((1, self.size)), ())
|
|
232
|
+
# normalize a negative axis (numpy semantics)
|
|
233
|
+
if not -len(self.shape) <= axis < len(self.shape):
|
|
234
|
+
raise ValueError(f"axis {axis} is out of bounds for shape {self.shape}.")
|
|
235
|
+
axis %= len(self.shape)
|
|
219
236
|
idx = np.arange(self.size).reshape(self.shape)
|
|
220
237
|
kept = tuple(d for a, d in enumerate(self.shape) if a != axis)
|
|
221
238
|
out_m = int(np.prod(kept)) if kept else 1
|
|
222
239
|
# build summation matrix: out row r sums the flat cols collapsed into it
|
|
223
240
|
keep_idx = np.moveaxis(idx, axis, -1).reshape(out_m, self.shape[axis])
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
241
|
+
rows = np.repeat(np.arange(out_m), self.shape[axis])
|
|
242
|
+
S = sp.csr_matrix(
|
|
243
|
+
(np.ones(keep_idx.size), (rows, keep_idx.reshape(-1))), shape=(out_m, self.size)
|
|
244
|
+
)
|
|
245
|
+
return self._row_op(S, kept)
|
|
228
246
|
|
|
229
247
|
def __getitem__(self, idx):
|
|
230
248
|
# numpy-style indexing -> row selection
|
|
231
249
|
sel = np.arange(self.size).reshape(self.shape)[idx]
|
|
232
250
|
out_shape = sel.shape if hasattr(sel, "shape") else ()
|
|
233
251
|
sel = np.atleast_1d(sel).reshape(-1)
|
|
234
|
-
S = sp.csr_matrix(
|
|
252
|
+
S = sp.csr_matrix(
|
|
253
|
+
(np.ones(sel.size), (np.arange(sel.size), sel)), shape=(sel.size, self.size)
|
|
254
|
+
)
|
|
235
255
|
return self._row_op(S, out_shape)
|
|
236
256
|
|
|
237
257
|
# ---- constraints ----
|
|
@@ -290,8 +310,10 @@ class Quadratic:
|
|
|
290
310
|
# Quadratic + (Affine | Variable | Quadratic | const) -> Quadratic
|
|
291
311
|
if isinstance(o, Variable):
|
|
292
312
|
o = o._to_affine()
|
|
293
|
-
# affine / constant folds into the linear part
|
|
313
|
+
# affine / constant folds into the scalar linear part
|
|
294
314
|
if isinstance(o, Affine):
|
|
315
|
+
if o.size != 1:
|
|
316
|
+
raise TypeError("A Quadratic is scalar; reduce the added term with .sum().")
|
|
295
317
|
return Quadratic(self.quad, self.affine + o)
|
|
296
318
|
# merge the two quadratic block dicts
|
|
297
319
|
if isinstance(o, Quadratic):
|
|
@@ -307,6 +329,14 @@ class Quadratic:
|
|
|
307
329
|
# reflected add
|
|
308
330
|
return self + o
|
|
309
331
|
|
|
332
|
+
def __sub__(self, o):
|
|
333
|
+
# subtract via negated add
|
|
334
|
+
return self + (-o)
|
|
335
|
+
|
|
336
|
+
def __rsub__(self, o):
|
|
337
|
+
# reflected subtract
|
|
338
|
+
return (-self) + o
|
|
339
|
+
|
|
310
340
|
def __mul__(self, o):
|
|
311
341
|
# scale the quadratic and its affine part by a scalar
|
|
312
342
|
if _is_num(o):
|
|
@@ -318,6 +348,16 @@ class Quadratic:
|
|
|
318
348
|
# reflected scale
|
|
319
349
|
return self * o
|
|
320
350
|
|
|
351
|
+
def __truediv__(self, o):
|
|
352
|
+
# scale by the reciprocal scalar
|
|
353
|
+
if _is_num(o):
|
|
354
|
+
return self * (1.0 / float(o))
|
|
355
|
+
return NotImplemented
|
|
356
|
+
|
|
357
|
+
def __neg__(self):
|
|
358
|
+
# negate the quadratic and its affine part
|
|
359
|
+
return self * -1.0
|
|
360
|
+
|
|
321
361
|
# ---- quadratic constraints ----
|
|
322
362
|
def __le__(self, rhs):
|
|
323
363
|
return Constraint(self, "<=", rhs)
|
|
@@ -339,9 +379,12 @@ class Quadratic:
|
|
|
339
379
|
|
|
340
380
|
def _is_row_selection(S, rows):
|
|
341
381
|
# one entry of value 1 per row, distinct columns (a plain index / slice pick)
|
|
342
|
-
return (
|
|
343
|
-
|
|
344
|
-
|
|
382
|
+
return (
|
|
383
|
+
S.nnz == rows
|
|
384
|
+
and (S.data == 1).all()
|
|
385
|
+
and np.array_equal(S.indptr, np.arange(rows + 1))
|
|
386
|
+
and len(set(S.indices)) == S.nnz
|
|
387
|
+
)
|
|
345
388
|
|
|
346
389
|
|
|
347
390
|
def _as_selection(operand):
|
|
@@ -349,12 +392,14 @@ def _as_selection(operand):
|
|
|
349
392
|
if isinstance(operand, Variable):
|
|
350
393
|
return operand, None
|
|
351
394
|
if isinstance(operand, Affine) and len(operand.blocks) == 1 and not operand.const.any():
|
|
352
|
-
(var, S), = operand.blocks.items()
|
|
395
|
+
((var, S),) = operand.blocks.items()
|
|
353
396
|
S = S.tocsr()
|
|
354
397
|
if _is_row_selection(S, operand.size):
|
|
355
|
-
return var, S.indices.copy()
|
|
356
|
-
raise TypeError(
|
|
357
|
-
|
|
398
|
+
return var, S.indices.copy() # selected columns of var
|
|
399
|
+
raise TypeError(
|
|
400
|
+
"A Parameter may only multiply a Variable or a plain slice / index "
|
|
401
|
+
"of one (e.g. x, x[:k], x[idx])."
|
|
402
|
+
)
|
|
358
403
|
|
|
359
404
|
|
|
360
405
|
def _make_objective(param, base, sel, inner=False):
|
|
@@ -362,10 +407,14 @@ def _make_objective(param, base, sel, inner=False):
|
|
|
362
407
|
var, local_idx = _as_selection(sel)
|
|
363
408
|
# `@` is a 1-D inner product; a multi-dimensional cost must go through (c * x).sum()
|
|
364
409
|
if inner and len(getattr(sel, "shape", ())) > 1:
|
|
365
|
-
raise TypeError(
|
|
410
|
+
raise TypeError(
|
|
411
|
+
"c @ x is a 1-D inner product; for a multi-dimensional cost write (c * x).sum()."
|
|
412
|
+
)
|
|
366
413
|
n = var.size if local_idx is None else len(local_idx)
|
|
367
414
|
if param.size != n:
|
|
368
|
-
raise TypeError(
|
|
415
|
+
raise TypeError(
|
|
416
|
+
f"Parameter size {param.size} does not match the selected variable size {n}."
|
|
417
|
+
)
|
|
369
418
|
return ParametricObjective(param, var, local_idx, base=base)
|
|
370
419
|
|
|
371
420
|
|
|
@@ -385,7 +434,7 @@ class Parameter:
|
|
|
385
434
|
"""
|
|
386
435
|
|
|
387
436
|
def __init__(self, shape, *, name=None):
|
|
388
|
-
self.shape =
|
|
437
|
+
self.shape = tuple(int(s) for s in np.atleast_1d(shape))
|
|
389
438
|
self.size = int(np.prod(self.shape)) if self.shape else 1
|
|
390
439
|
self.name = name
|
|
391
440
|
|
|
@@ -418,8 +467,10 @@ class Parameter:
|
|
|
418
467
|
return self._forbid()
|
|
419
468
|
|
|
420
469
|
def _forbid(self, *a, **k):
|
|
421
|
-
raise TypeError(
|
|
422
|
-
|
|
470
|
+
raise TypeError(
|
|
471
|
+
"Unsupported operation on Parameter (only c @ var / c * var, "
|
|
472
|
+
"optionally with a known base: (d + c) @ var)."
|
|
473
|
+
)
|
|
423
474
|
|
|
424
475
|
__rsub__ = __neg__ = __getitem__ = __le__ = __ge__ = __eq__ = _forbid
|
|
425
476
|
|
|
@@ -437,7 +488,10 @@ class ParametricCoef:
|
|
|
437
488
|
|
|
438
489
|
def __init__(self, param, base):
|
|
439
490
|
self.param = param
|
|
440
|
-
|
|
491
|
+
base = np.asarray(base, dtype=float)
|
|
492
|
+
# multi-dim bases broadcast over the parameter's logical shape, flat ones over its size
|
|
493
|
+
shape = param.shape if base.ndim > 1 else (param.size,)
|
|
494
|
+
self.base = np.broadcast_to(base, shape).reshape(-1).astype(float)
|
|
441
495
|
|
|
442
496
|
__array_ufunc__ = None
|
|
443
497
|
|
|
@@ -470,7 +524,9 @@ class ParametricVector:
|
|
|
470
524
|
var, local_idx = _as_selection(sel)
|
|
471
525
|
n = var.size if local_idx is None else len(local_idx)
|
|
472
526
|
if param.size != n:
|
|
473
|
-
raise TypeError(
|
|
527
|
+
raise TypeError(
|
|
528
|
+
f"Parameter size {param.size} does not match the selected variable size {n}."
|
|
529
|
+
)
|
|
474
530
|
self.param = param
|
|
475
531
|
self.base = base
|
|
476
532
|
self.sel = sel
|
|
@@ -481,12 +537,16 @@ class ParametricVector:
|
|
|
481
537
|
def sum(self, axis=None):
|
|
482
538
|
# reduce to the scalar predicted objective term
|
|
483
539
|
if axis is not None:
|
|
484
|
-
raise TypeError(
|
|
540
|
+
raise TypeError(
|
|
541
|
+
"A predicted objective must reduce to a scalar; use .sum() with no axis."
|
|
542
|
+
)
|
|
485
543
|
return _make_objective(self.param, self.base, self.sel)
|
|
486
544
|
|
|
487
545
|
def __add__(self, o):
|
|
488
|
-
raise TypeError(
|
|
489
|
-
|
|
546
|
+
raise TypeError(
|
|
547
|
+
"c * x is an elementwise vector; reduce it with .sum() before adding "
|
|
548
|
+
"other terms, e.g. (c * x).sum() + d @ y."
|
|
549
|
+
)
|
|
490
550
|
|
|
491
551
|
__radd__ = __add__
|
|
492
552
|
|
|
@@ -519,9 +579,14 @@ class ParametricObjective:
|
|
|
519
579
|
|
|
520
580
|
def _with(self, fixed=None, quad=None):
|
|
521
581
|
# copy with a replaced fixed / quad term
|
|
522
|
-
return ParametricObjective(
|
|
523
|
-
|
|
524
|
-
|
|
582
|
+
return ParametricObjective(
|
|
583
|
+
self.cost_param,
|
|
584
|
+
self.cost_var,
|
|
585
|
+
self.local_idx,
|
|
586
|
+
self.base,
|
|
587
|
+
self.fixed if fixed is None else fixed,
|
|
588
|
+
self.quad if quad is None else quad,
|
|
589
|
+
)
|
|
525
590
|
|
|
526
591
|
def sum(self, axis=None):
|
|
527
592
|
# already a scalar; sum is the identity here
|
|
@@ -530,12 +595,17 @@ class ParametricObjective:
|
|
|
530
595
|
def __add__(self, o):
|
|
531
596
|
# attach a known linear (Affine / Variable) or quadratic (Quadratic) term
|
|
532
597
|
if isinstance(o, ParametricObjective):
|
|
533
|
-
raise TypeError(
|
|
598
|
+
raise TypeError(
|
|
599
|
+
"Cannot add two predicted cost terms; a problem has exactly one predicted cost."
|
|
600
|
+
)
|
|
534
601
|
if isinstance(o, Quadratic):
|
|
535
602
|
return self._with(quad=o if self.quad is None else self.quad + o)
|
|
536
603
|
if isinstance(o, Variable):
|
|
537
604
|
o = o._to_affine()
|
|
538
605
|
if isinstance(o, Affine):
|
|
606
|
+
# a vector objective term is meaningless; require a scalar
|
|
607
|
+
if o.size != 1:
|
|
608
|
+
raise TypeError("A known objective term must be scalar; reduce it with .sum().")
|
|
539
609
|
return self._with(fixed=o if self.fixed is None else self.fixed + o)
|
|
540
610
|
if _is_num(o):
|
|
541
611
|
raise TypeError("A constant objective term has no effect; omit it.")
|
|
@@ -552,7 +622,7 @@ class Constraint:
|
|
|
552
622
|
Attributes:
|
|
553
623
|
lhs (Affine | Quadratic): left-hand side expression
|
|
554
624
|
sense (str): one of ``"<="`` / ``">="`` / ``"=="``
|
|
555
|
-
rhs (np.ndarray): right-hand side, broadcast
|
|
625
|
+
rhs (np.ndarray): right-hand side, broadcast over the LHS shape
|
|
556
626
|
"""
|
|
557
627
|
|
|
558
628
|
def __init__(self, lhs, sense, rhs):
|
|
@@ -599,5 +669,9 @@ class Constraint:
|
|
|
599
669
|
return None, A, self.sense, self._rhs_vec(aff.size) - aff.const
|
|
600
670
|
|
|
601
671
|
def _rhs_vec(self, m):
|
|
602
|
-
# broadcast the rhs
|
|
603
|
-
|
|
672
|
+
# broadcast the rhs over the LHS logical shape; a flat full-length vector is kept as-is
|
|
673
|
+
rhs = np.asarray(self.rhs, dtype=float)
|
|
674
|
+
if rhs.ndim == 1 and rhs.size == m:
|
|
675
|
+
return rhs.astype(float)
|
|
676
|
+
shape = getattr(self.lhs, "shape", ()) or (m,)
|
|
677
|
+
return np.broadcast_to(rhs, shape).reshape(-1).astype(float)
|
|
@@ -25,14 +25,19 @@ class Objective:
|
|
|
25
25
|
|
|
26
26
|
def __init__(self, expr):
|
|
27
27
|
from pyepo.dsl.expression import ParametricObjective, ParametricVector
|
|
28
|
+
|
|
28
29
|
# an elementwise c * x is a vector, not a scalar objective
|
|
29
30
|
if isinstance(expr, ParametricVector):
|
|
30
|
-
raise TypeError(
|
|
31
|
-
|
|
31
|
+
raise TypeError(
|
|
32
|
+
"c * x is an elementwise vector, not a scalar objective; "
|
|
33
|
+
"write (c * x).sum() or c @ x."
|
|
34
|
+
)
|
|
32
35
|
# the objective must carry the predicted cost (a ParametricObjective)
|
|
33
36
|
if not isinstance(expr, ParametricObjective):
|
|
34
|
-
raise TypeError(
|
|
35
|
-
|
|
37
|
+
raise TypeError(
|
|
38
|
+
"The objective must be a predicted cost term like c @ x, "
|
|
39
|
+
"optionally plus a known quadratic term."
|
|
40
|
+
)
|
|
36
41
|
self.expr = expr
|
|
37
42
|
|
|
38
43
|
@property
|
|
@@ -46,9 +51,11 @@ class Objective:
|
|
|
46
51
|
|
|
47
52
|
class Minimize(Objective):
|
|
48
53
|
"""Minimization objective."""
|
|
54
|
+
|
|
49
55
|
modelSense = EPO.MINIMIZE
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
class Maximize(Objective):
|
|
53
59
|
"""Maximization objective."""
|
|
60
|
+
|
|
54
61
|
modelSense = EPO.MAXIMIZE
|