pyepo 2.2.0__tar.gz → 2.2.1__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.
Files changed (107) hide show
  1. {pyepo-2.2.0 → pyepo-2.2.1}/PKG-INFO +4 -2
  2. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/data/dataset.py +62 -32
  3. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/data/knapsack.py +2 -0
  4. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/data/shortestpath.py +2 -0
  5. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/data/tsp.py +2 -0
  6. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/dsl/compiled.py +29 -7
  7. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/dsl/expression.py +106 -32
  8. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/dsl/objective.py +11 -4
  9. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/dsl/problem.py +41 -6
  10. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/__init__.py +36 -18
  11. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/blackbox.py +7 -2
  12. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/__init__.py +8 -8
  13. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/abcmodule.py +17 -11
  14. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/blackbox.py +54 -49
  15. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/cave.py +15 -6
  16. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/contrastive.py +3 -3
  17. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/perturbed.py +155 -149
  18. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/rank.py +8 -8
  19. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/regularized.py +23 -14
  20. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/surrogate.py +6 -5
  21. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/jax/utils.py +79 -44
  22. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/perturbed.py +22 -7
  23. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/regularized.py +19 -5
  24. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/surrogate.py +5 -5
  25. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/utils.py +29 -16
  26. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/metric/metrics.py +56 -35
  27. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/metric/mse.py +7 -5
  28. pyepo-2.2.1/pyepo/metric/regret.py +200 -0
  29. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/metric/unambregret.py +31 -24
  30. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/compile.py +14 -5
  31. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/coptmodel.py +14 -7
  32. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/knapsack.py +8 -1
  33. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/portfolio.py +4 -1
  34. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/shortestpath.py +8 -6
  35. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/tsp.py +7 -3
  36. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/vrp.py +11 -4
  37. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/compile.py +9 -2
  38. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/grbmodel.py +35 -2
  39. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/knapsack.py +3 -0
  40. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/shortestpath.py +4 -6
  41. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/tsp.py +23 -2
  42. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/vrp.py +50 -6
  43. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/mpax/compile.py +44 -11
  44. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/mpax/mpaxmodel.py +14 -3
  45. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/compile.py +26 -8
  46. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/knapsack.py +5 -1
  47. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/omomodel.py +12 -1
  48. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/portfolio.py +1 -1
  49. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/vrp.py +4 -1
  50. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/opt.py +6 -0
  51. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/ort/compile.py +14 -7
  52. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/ort/knapsack.py +5 -1
  53. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/ort/ortmodel.py +9 -2
  54. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/predefined.py +6 -2
  55. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/utils.py +13 -1
  56. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo.egg-info/PKG-INFO +4 -2
  57. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo.egg-info/requires.txt +4 -1
  58. {pyepo-2.2.0 → pyepo-2.2.1}/pyproject.toml +3 -2
  59. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_15_dsl.py +168 -4
  60. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_20_data_gen.py +12 -0
  61. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_30_model.py +133 -15
  62. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_40_dataset.py +11 -10
  63. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_50_func.py +240 -38
  64. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_55_jax.py +256 -96
  65. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_60_metric.py +218 -6
  66. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_70_twostage.py +1 -1
  67. pyepo-2.2.0/pyepo/metric/regret.py +0 -119
  68. {pyepo-2.2.0 → pyepo-2.2.1}/LICENSE +0 -0
  69. {pyepo-2.2.0 → pyepo-2.2.1}/README.md +0 -0
  70. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/EPO.py +0 -0
  71. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/__init__.py +0 -0
  72. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/data/__init__.py +0 -0
  73. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/data/portfolio.py +0 -0
  74. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/dsl/__init__.py +0 -0
  75. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/abcmodule.py +0 -0
  76. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/cave.py +0 -0
  77. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/contrastive.py +0 -0
  78. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/func/rank.py +2 -2
  79. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/metric/__init__.py +0 -0
  80. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/__init__.py +0 -0
  81. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/bases.py +0 -0
  82. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/copt/__init__.py +0 -0
  83. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/__init__.py +0 -0
  84. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/grb/portfolio.py +0 -0
  85. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/mpax/__init__.py +0 -0
  86. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/mpax/knapsack.py +0 -0
  87. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/mpax/shortestpath.py +0 -0
  88. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/__init__.py +0 -0
  89. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/shortestpath.py +0 -0
  90. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/omo/tsp.py +0 -0
  91. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/ort/__init__.py +0 -0
  92. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/ort/ortcpmodel.py +0 -0
  93. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/model/ort/shortestpath.py +0 -0
  94. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/py.typed +0 -0
  95. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/twostage/__init__.py +0 -0
  96. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/twostage/autosklearnpred.py +0 -0
  97. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/twostage/sklearnpred.py +0 -0
  98. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo/utils.py +0 -0
  99. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo.egg-info/SOURCES.txt +0 -0
  100. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo.egg-info/dependency_links.txt +0 -0
  101. {pyepo-2.2.0 → pyepo-2.2.1}/pyepo.egg-info/top_level.txt +0 -0
  102. {pyepo-2.2.0 → pyepo-2.2.1}/setup.cfg +0 -0
  103. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_00_constants.py +0 -0
  104. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_10_utils.py +0 -0
  105. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_80_integration.py +0 -0
  106. {pyepo-2.2.0 → pyepo-2.2.1}/tests/test_85_backend_pipeline.py +0 -0
  107. {pyepo-2.2.0 → pyepo-2.2.1}/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.0
3
+ Version: 2.2.1
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
- try:
90
- sol, obj = self._solve(c)
91
- # to numpy
92
- if isinstance(sol, torch.Tensor):
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.setObj(model._fullCost(self.costs))
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 setObj already negated c for MAX
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.setObj(self.model._fullCost(cost))
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
- try:
245
- sol_i, obj_i = self._solve(c)
246
- if isinstance(sol_i, torch.Tensor):
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 with a fresh
285
- copy of the Gurobi model, and extracts the **normals of the binding
286
- constraints at the optimal vertex** in canonical ``<=`` orientation.
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
- # fresh per-instance copy keeps the lazy-constraint buffer clean
360
- model = self.model.copy()
361
- model.setObj(model._fullCost(c))
362
- sol, obj = model.solve()
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
- if not np.all((sol_arr < 1e-5) | (sol_arr > 1 - 1e-5)):
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
- # project the constraint matrix onto cost-variable columns
459
- cost_col_idx = np.asarray([v.index for v in cost_vars])
460
- A = grb.getA().tocsr()
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 = A[:, cost_col_idx][tight_mask].toarray()
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
- if coef.shape[-1] != prob.num_vars:
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(-1, index, pred_cost)
61
- return scattered + torch.as_tensor(prob.fixed_cost, dtype=pred_cost.dtype, device=pred_cost.device)
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
- return np.asarray(sol), obj
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
- return self._add_cut(np.asarray(coefs, dtype=float).reshape(-1), rhs)
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
- return type(self)(**kwargs)
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 = (int(shape),) if np.isscalar(shape) else tuple(int(s) for s in 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
- return Affine(self.blocks, self.const + np.asarray(o, dtype=float).reshape(-1), self.shape)
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
- S = sp.lil_matrix((out_m, self.size))
225
- for r in range(out_m):
226
- S[r, keep_idx[r]] = 1.0
227
- return self._row_op(S.tocsr(), kept)
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((np.ones(sel.size), (np.arange(sel.size), sel)), shape=(sel.size, self.size))
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 (S.nnz == rows and (S.data == 1).all()
343
- and np.array_equal(S.indptr, np.arange(rows + 1))
344
- and len(set(S.indices)) == S.nnz)
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() # selected columns of var
356
- raise TypeError("A Parameter may only multiply a Variable or a plain slice / index "
357
- "of one (e.g. x, x[:k], x[idx]).")
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("c @ x is a 1-D inner product; for a multi-dimensional cost write (c * x).sum().")
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(f"Parameter size {param.size} does not match the selected variable size {n}.")
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 = (int(shape),) if np.isscalar(shape) else tuple(int(s) for s in 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("Unsupported operation on Parameter (only c @ var / c * var, "
422
- "optionally with a known base: (d + c) @ var).")
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
- self.base = np.broadcast_to(np.asarray(base, dtype=float), (param.size,)).copy()
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(f"Parameter size {param.size} does not match the selected variable size {n}.")
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("A predicted objective must reduce to a scalar; use .sum() with no axis.")
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("c * x is an elementwise vector; reduce it with .sum() before adding "
489
- "other terms, e.g. (c * x).sum() + d @ y.")
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(self.cost_param, self.cost_var, self.local_idx, self.base,
523
- self.fixed if fixed is None else fixed,
524
- self.quad if quad is None else quad)
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("Cannot add two predicted cost terms; a problem has exactly one predicted cost.")
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 to the LHS length
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 to a flat (m,) vector
603
- return np.broadcast_to(np.asarray(self.rhs, dtype=float), (m,)).astype(float)
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("c * x is an elementwise vector, not a scalar objective; "
31
- "write (c * x).sum() or c @ x.")
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("The objective must be a predicted cost term like c @ x, "
35
- "optionally plus a known quadratic term.")
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