polyblocks 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.
polyblocks/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .abstract import ABPolyblock, Solution
2
+ from .naive import BalancedPOA, BasePOA
3
+ from .tree import TreePOA
4
+
5
+ __all__ = ["ABPolyblock", "BalancedPOA", "BasePOA", "Solution", "TreePOA"]
polyblocks/abstract.py ADDED
@@ -0,0 +1,301 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from functools import partial
5
+ from time import perf_counter
6
+
7
+ import numpy as np
8
+ from numpy.typing import ArrayLike, NDArray
9
+
10
+ from .utils import monotone_proj, print_row, tighten
11
+
12
+
13
+ @dataclass
14
+ class Solution:
15
+ """
16
+ The result returned by `ABPolyblock.solve`.
17
+
18
+ Attributes:
19
+ x: Optimal (or best attained) solution.
20
+ obj: Optimal (or best attained) objective value.
21
+ best_bound: Best upper-bound on the optimal objective.
22
+ success: Whether the solver returned an optimal solution.
23
+ status: Description of the termination status.
24
+ n_iter: Number of iterations executed.
25
+ """
26
+
27
+ x: NDArray | None = None
28
+ obj: float = -np.inf
29
+ best_bound: float = np.inf
30
+ success: bool = False
31
+ status: str = "Maximum iterations reached"
32
+ n_iter: int = 0
33
+
34
+
35
+ class ABPolyblock(ABC):
36
+ """
37
+ An abstract class for custom implementations of the Polyblock Outer-approximation (POA) algorithm.
38
+
39
+ POA maximises an increasing objective over `G ∩ H ∩ [x_l, x_u]`, where `G` is a normal set given by `ub_oracle` and `H` a co-normal set given by `lb_oracle`.
40
+ It does so by maintaining a *polyblock*: a finite vertex set `V` whose union of boxes `(-inf, v]` contains every feasible point which could still improve the incumbent.
41
+ Each iteration projects vertices of `V` onto the boundary of `G` and cuts the
42
+ infeasible cone strictly above each projection out of the polyblock, so the outer-approximation tightens until `V` empties and the incumbent is certified optimal.
43
+
44
+ Child classes choose how that vertex set is stored and refined, and `solve` drives the representation through one cycle per iteration:
45
+
46
+ 1. `projection_pairs` selects the vertices to refine, each paired with a feasible anchor below it.
47
+ 2. `solve` projects each vertex towards its anchor onto the boundary of the eroded set `G_delta`, giving `proj`, and takes the shifted candidates `min(proj + delta, x_u)` as feasible solutions.
48
+ 3. `set_min_obj` is called with a new objective cut-off, but only when a candidate improves the incumbent.
49
+ 4. `new_vertices` cuts the cones above `proj` out of the polyblock and returns the replacement vertices.
50
+ 5. `solve` evaluates the objective on those vertices and passes a feasibility mask to `update`, which reports whether the vertex set is now exhausted.
51
+
52
+ Implementing `__init__`, `projection_pairs`, `set_min_obj`, `new_vertices`, and `update` is therefore enough to define a solver.
53
+ The `size` property is used for limiting memory usage, while the `best_bound` property is optional and only used for progress reporting.
54
+
55
+ Attributes:
56
+ POLYBLOCK_LIMIT: Maximum permitted `size` of the polyblock container.
57
+ """
58
+
59
+ POLYBLOCK_LIMIT = 2 * int(1e8)
60
+
61
+ @abstractmethod
62
+ def __init__(self, lower: NDArray, upper: NDArray) -> None:
63
+ """
64
+ Initialises polyblock representation using lower and upper points which define the feasible rectangle.
65
+
66
+ Both points have already been tightened against the oracles by `solve`, so the initial polyblock is the single vertex `upper`.
67
+
68
+ Args:
69
+ lower: Lower point of the feasible rectangle, of shape `(dim,)`.
70
+ upper: Upper point of the feasible rectangle, of shape `(dim,)`.
71
+ """
72
+
73
+ @abstractmethod
74
+ def projection_pairs(self) -> tuple[NDArray, NDArray]:
75
+ """
76
+ Returns pairs of anchors and vertices for computing monotone projections.
77
+
78
+ Called once at the start of each iteration to choose which vertices to refine.
79
+ The selection policy is free, subject only to the requirement that the *maximal* vertex, the one of greatest objective value in the polyblock, is always among those returned; convergence rests on it.
80
+ Returning several pairs cuts several cones per iteration, where downstream routines apply in a vectorised fashion.
81
+
82
+ Each anchor need only lie in `G` and strictly below its vertex; the `delta` shift keeping it feasible for the eroded set is applied downstream.
83
+
84
+ Returns:
85
+ A tuple `(anchors, vertices)`, each of shape `(num_pairs, dim)`:
86
+ anchors: Feasible points which project their paired vertex.
87
+ vertices: Polyblock vertices to project onto their paired anchor.
88
+ """
89
+
90
+ @abstractmethod
91
+ def set_min_obj(self, obj: float) -> None:
92
+ """
93
+ Update minimum objective value of future candidate solutions, discarding those which fall below it.
94
+
95
+ The polyblock only has to cover feasible points which beat the incumbent by the optimality tolerance, so a vertex falling below `obj` may be dropped: its objective bounds the whole box below it.
96
+ Called only when the incumbent improves, so `obj` increases monotonically over a solve.
97
+
98
+ Args:
99
+ obj: Objective cut-off for retained vertices.
100
+ """
101
+
102
+ @abstractmethod
103
+ def new_vertices(self, proj: NDArray, delta: float) -> NDArray:
104
+ """
105
+ Returns refined polyblock vertices to be checked for feasibility.
106
+
107
+ Cuts the cones above `proj` out of the polyblock, returning the vertices which replace those they remove.
108
+ A vertex lying above a projection is refined by reducing each of its components in turn to the matching component of that projection, giving up to `dim` replacements.
109
+ The choice of which vertices are refined, a vertex lying above several projections is handled is left to the implementation.
110
+ The implementation decides which vertices are refined, the projections used to refine them, and how redundancy checking is handled.
111
+
112
+ New vertiecs are internally retained, since `update` reports feasibility against it positionally.
113
+
114
+ Args:
115
+ proj: Monotone projections of this iteration's vertices, of shape `(num_pairs, dim)`.
116
+ delta: Erosion factor, giving the margin within which a vertex need not be refined for partial refinements.
117
+
118
+ Returns:
119
+ Candidate vertices of shape `(num_new, dim)`, pending the feasibility check in `update`.
120
+ All vertices should lie within the feasible box.
121
+ """
122
+
123
+ @abstractmethod
124
+ def update(self, new_mask: NDArray[np.bool], new_obj: NDArray) -> bool:
125
+ """
126
+ Updates internal polyblock representation after checking new vertex feasibility.
127
+
128
+ `new_mask` indexes the array returned by `new_vertices` in the same order, while `new_obj` holds objective values for the masked entries alone.
129
+ Masked-out vertices either violate the co-normal constraints or fall below the objective cut-off, and should be discarded rather than stored.
130
+
131
+ Args:
132
+ new_mask: Refined vertex feasibility, of shape `(num_new,)`.
133
+ new_obj: Objective values of feasible refined vertices, of shape `(new_mask.sum(),)`.
134
+
135
+ Returns:
136
+ True once the vertex set is exhausted, which terminates `solve` and certifies the incumbent as optimal, or the problem as infeasible if no candidate was ever found.
137
+ """
138
+
139
+ @property
140
+ @abstractmethod
141
+ def size(self) -> int:
142
+ """
143
+ Size of the container used to store polyblocks. Used for limiting memory usage.
144
+
145
+ Polled every iteration, and `solve` gives up once it exceeds `POLYBLOCK_LIMIT`.
146
+ Units are left to the implementation, as the limit is only ever compared against this property.
147
+ """
148
+
149
+ @property
150
+ def best_bound(self) -> float:
151
+ """
152
+ Best upper-bound on optimal objective value (optional).
153
+
154
+ Defaults to `nan` for implementations which do not track a bound.
155
+ This property is only used for reporting purposes and does not affect the solve.
156
+ """
157
+ return np.nan
158
+
159
+ @classmethod
160
+ def solve(
161
+ cls,
162
+ obj: Callable[[NDArray[np.floating]], NDArray[np.floating]],
163
+ x_l: ArrayLike,
164
+ x_u: ArrayLike,
165
+ ub_oracle: Callable[[NDArray[np.floating]], NDArray[np.bool]],
166
+ lb_oracle: Callable[[NDArray[np.floating]], NDArray[np.bool]] | None = None,
167
+ eps_obj_abs: float = 1e-6,
168
+ eps_obj_rel: float = 1e-2,
169
+ eps_ls: float = 1e-3,
170
+ delta: float = 1e-3,
171
+ verbose_gap: int | None = None,
172
+ time_limit: int = 3600,
173
+ iteration_limit: int = int(1e8),
174
+ ) -> Solution:
175
+ """
176
+ A template for the Polyblock Outer-approximation algorithm.
177
+
178
+ Maximises `obj` over the points of `[x_l, x_u]` accepted by both oracles.
179
+ The objective must be non-decreasing in every coordinate, while `ub_oracle` and `lb_oracle` should be boolean-valued functions querying upper- and lower-bound constraints, respectively.
180
+ All three functions are called with 2D arrays batching points along the first axis.
181
+
182
+ A returned solution is always feasible, and no feasible point beats it by more than the objective tolerances, except possibly points lying within `delta` of the upper-bound constraint boundary.
183
+
184
+ Args:
185
+ obj: Non-decreasing objective function.
186
+ x_l: Lower point defining feasible rectangle [x_l, x_u], must broadcast with `x_u` to a valid input for the objective and oracles.
187
+ x_u: Upper point defining feasible rectangle [x_l, x_u], must broadcast with `x_l` to a valid input for the objective and oracles.
188
+ ub_oracle: Oracle querying upper-bound (normal) constraints.
189
+ lb_oracle: Oracle querying lower-bound (co-normal) constraints (optional).
190
+ eps_obj_abs: Absolute tolerance between incumbent objective and upper-bound.
191
+ eps_obj_rel: Relative tolerance between incumbent objective and upper-bound.
192
+ eps_ls: Line-search tolerance.
193
+ delta: Optimality relaxation for upper-bound constraints. The objective is optimised only over the `delta` eroded normal set.
194
+ verbose_gap: Number of iterations between printout (optional).
195
+ iteration_limit: Maximum number of POA iterations before the solver gives up.
196
+ time_limit: Maximum solver runtime in seconds.
197
+
198
+ Returns:
199
+ The best solution found, see `Solution`. Note that `success` reports whether the solver terminated on its own certificate rather than a limit, so it is also set when the problem is proven infeasible.
200
+
201
+ Raises:
202
+ ValueError: If `x_u` is smaller than `x_l` in any coordinate.
203
+ """
204
+
205
+ lb_exists = lb_oracle is not None
206
+ verbose = verbose_gap is not None
207
+
208
+ ## returned solution
209
+ sol = Solution()
210
+
211
+ ## initial checks
212
+ x_l, x_u = np.broadcast_arrays(x_l, x_u)
213
+ if (x_u - x_l < 0).any():
214
+ raise ValueError("`x_u` must be no smaller than `x_l` element-wise.")
215
+ elif not ub_oracle(x_l[None]):
216
+ sol.success = True
217
+ sol.status = "Problem Infeasible since `x_l` not in normal set."
218
+ if verbose:
219
+ print(sol.status)
220
+ return sol
221
+
222
+ ## tighten initial box
223
+ x_u = tighten(x_l, x_u, oracle=ub_oracle)
224
+ x_l = tighten(x_u, x_l, oracle=lb_oracle) if lb_exists else x_l.copy()
225
+
226
+ mono_proj = partial(monotone_proj, oracle=ub_oracle, eps=eps_ls, delta=delta)
227
+ polyblock = cls(x_l, x_u)
228
+ min_obj = -np.inf
229
+
230
+ ## main loop
231
+ i = -1
232
+ start_time = perf_counter()
233
+ for i in range(iteration_limit):
234
+ ## compute projections from lower and upper points
235
+ lower_data, upper_data = polyblock.projection_pairs()
236
+ proj = mono_proj(lower_data, upper_data)
237
+ candidates = (proj + delta).clip(max=x_u)
238
+
239
+ ## find feasible candidates
240
+ feas_mask = (candidates >= x_l).all(-1)
241
+ if lb_exists:
242
+ feas_mask &= lb_oracle(candidates)
243
+
244
+ ## update best solution
245
+ if feas_mask.any():
246
+ cand_feas = candidates[feas_mask]
247
+ cand_obj = obj(cand_feas)
248
+ best_cand = cand_obj.argmax()
249
+ best_obj_cand = cand_obj[best_cand]
250
+ if best_obj_cand > sol.obj:
251
+ sol.obj = best_obj_cand.item()
252
+ sol.x = cand_feas[best_cand].copy()
253
+ min_obj = eps_obj_abs + sol.obj * (eps_obj_rel + 1)
254
+ polyblock.set_min_obj(min_obj)
255
+
256
+ ## update polyblock representation
257
+ new = polyblock.new_vertices(proj, delta=delta)
258
+ new_obj = obj(new).flatten()
259
+ new_mask = new_obj >= min_obj
260
+ if lb_exists:
261
+ new_mask &= lb_oracle(new)
262
+ new_obj = new_obj[new_mask]
263
+ empty = polyblock.update(new_mask, new_obj)
264
+
265
+ ## check termination
266
+ if empty:
267
+ sol.success = True
268
+ if sol.x is not None:
269
+ sol.status = "Optimal!"
270
+ else:
271
+ sol.status = "Infeasible under current relaxation."
272
+ break
273
+ elif polyblock.size > cls.POLYBLOCK_LIMIT:
274
+ sol.status = "Maximum polyblock size exceeded."
275
+ break
276
+ elif perf_counter() - start_time > time_limit:
277
+ sol.status = "Time limit exceeded."
278
+ break
279
+
280
+ if verbose and i % verbose_gap == 0:
281
+ header = i % (verbose_gap * 50) == 0
282
+ print_row(
283
+ header=header,
284
+ iter=i,
285
+ obj=sol.obj,
286
+ best_bound=polyblock.best_bound,
287
+ poly_size=polyblock.size,
288
+ dist_to_boundary=(upper_data - proj).mean().item(),
289
+ )
290
+
291
+ sol.n_iter = i + 1
292
+ sol.best_bound = polyblock.best_bound
293
+ if verbose:
294
+ print(
295
+ f"{sol.status}, "
296
+ f"itr: {sol.n_iter}, "
297
+ f"Best solution: {sol.x}, "
298
+ f"Obj: {sol.obj:.3f}"
299
+ )
300
+
301
+ return sol
@@ -0,0 +1,179 @@
1
+ import numpy as np
2
+ from numpy.typing import ArrayLike, DTypeLike, NDArray
3
+
4
+ from .jit_funcs import delete, find_best, query_multi, rebuild, update_obj
5
+
6
+
7
+ class DynamicArray:
8
+ """
9
+ A 2D numpy array with over-allocated memory to improve append performance along first axis.
10
+
11
+ Data range is not checked when indexing array.
12
+ """
13
+
14
+ __slots__ = ("data", "length")
15
+
16
+ def __init__(self, dim=1, dtype: DTypeLike = float, start_sz=100):
17
+ """Initialise dynamic container consisting of `dim` dimensional vectors of type `dtype`"""
18
+
19
+ size = (start_sz,) if dim == 1 else (start_sz, dim)
20
+ self.data = np.empty(size, dtype=dtype)
21
+ self.length = 0
22
+
23
+ def append(self, new: ArrayLike) -> None:
24
+ """Append vectors in `new` to the end of the array."""
25
+
26
+ data = self.data
27
+ row_shape = data.shape[1:]
28
+ new = np.asarray(new, dtype=data.dtype).reshape(-1, *row_shape)
29
+
30
+ max_size = data.shape[0]
31
+ length = self.length
32
+ new_len = length + new.shape[0]
33
+ if new_len > max_size:
34
+ new_size = max(max_size * 2, new_len)
35
+ new_data = np.empty((new_size, *row_shape), dtype=data.dtype)
36
+ new_data[:length] = data[:length]
37
+ self.data = data = new_data
38
+ data[length:new_len] = new
39
+ self.length = new_len
40
+
41
+ @property
42
+ def array(self):
43
+ return self.data[: self.length]
44
+
45
+ @property
46
+ def dtype(self):
47
+ return self.data.dtype
48
+
49
+ def __getitem__(self, index):
50
+ return self.data[index]
51
+
52
+ def __setitem__(self, index, val):
53
+ self.data[index] = val
54
+
55
+ def delete(self, removed_idx: NDArray[np.intp]) -> None:
56
+ """Deletes elements at `removed_idx` by shifting remaining elements down."""
57
+
58
+ delete(self.array, removed_idx)
59
+ self.length -= removed_idx.shape[0]
60
+
61
+
62
+ class Tree:
63
+ """Maintains a tree-representation of polyblock vertices."""
64
+
65
+ IDX_TYPE = np.int32
66
+ COMPONENT_TYPE = np.int8
67
+
68
+ def __init__(self, first: NDArray[np.float32 | np.float64]):
69
+ """Initialise tree using the root node vertex."""
70
+
71
+ self.first = first
72
+
73
+ ## tree representation
74
+ float_type = first.dtype
75
+ cvo_type = np.dtype(
76
+ [("comp", self.COMPONENT_TYPE), ("value", float_type), ("obj", float_type)]
77
+ )
78
+ self.cvo = DynamicArray(dim=1, dtype=cvo_type)
79
+ self.idx_range = DynamicArray(dim=2, dtype=self.IDX_TYPE)
80
+ self.parent = DynamicArray(dim=1, dtype=self.IDX_TYPE)
81
+
82
+ ## add first points
83
+ self.idx_range.append([-1, -1])
84
+ self.cvo.append((-1, -1, np.inf))
85
+ self.parent.append(-1)
86
+
87
+ def query(self, x: NDArray, lower: NDArray, min_obj=-np.inf, delta=1e-3) -> tuple:
88
+ """
89
+ Find and expand all leaf nodes which lie in the upper orthant of points `x`.
90
+
91
+ If a leaf node lies in more than one orthant, it is expanded using only the first valid point in `x`.
92
+
93
+ Args:
94
+ x: Array of shape `(num_points, dim)` containing points to query.
95
+ lower: Component-wise lower-bounds on tree vertices.
96
+ min_obj: Lower-bound on leaf objective permitted.
97
+ delta: Minimum distance from `x` required to expand a leaf.
98
+
99
+ Returns:
100
+ A tuple `(values, indices, vect, comp, cval)`:
101
+ values: Expanded leaf values.
102
+ indices: Indices of expanded leaves in the tree.
103
+ vect: Indices into `values` giving the parent leaf of each new node.
104
+ comp: Reduced component of each new node.
105
+ cval: New component values.
106
+ """
107
+
108
+ return query_multi(
109
+ x,
110
+ self.cvo.array,
111
+ self.idx_range.array,
112
+ self.first,
113
+ lower,
114
+ min_obj=min_obj,
115
+ delta=delta,
116
+ )
117
+
118
+ def find_best(self, num=1) -> NDArray:
119
+ """Find up to `num` different leaf node values, the first of which has the best objective."""
120
+
121
+ return find_best(self.cvo.array, self.idx_range.array, self.first, num=num)
122
+
123
+ def add(
124
+ self,
125
+ expanded: NDArray[np.intp],
126
+ exp_idx: NDArray[np.intp],
127
+ comp_idx: NDArray[np.intp],
128
+ comp_vals: NDArray[np.float32 | np.float64],
129
+ new_obj: NDArray[np.float32 | np.float64],
130
+ ) -> None:
131
+ """
132
+ Update internal tree representation by expanding leaf nodes.
133
+
134
+ Args:
135
+ expanded: Indices of expanded nodes.
136
+ exp_idx: Indices in `expanded` for parents of new nodes.
137
+ comp_idx: New expanded components.
138
+ comp_vals: New expanded component values.
139
+ new_obj: New objective values
140
+
141
+ """
142
+
143
+ cvo = self.cvo
144
+ parents = self.parent
145
+ idx_range = self.idx_range
146
+
147
+ ## collect new data
148
+ new_cvo = np.empty(comp_vals.shape[0], dtype=self.cvo.dtype)
149
+ new_cvo["comp"] = comp_idx
150
+ new_cvo["value"] = comp_vals
151
+ new_cvo["obj"] = new_obj
152
+
153
+ ## update parent neighbour ranges
154
+ num_expand = np.bincount(exp_idx, minlength=expanded.shape[0])
155
+ cumsum_expand = np.cumulative_sum(num_expand, include_initial=True)
156
+ cs_offset = cumsum_expand + cvo.length
157
+ par_ranges = np.column_stack((cs_offset[:-1], cs_offset[1:]))
158
+ idx_range[expanded] = par_ranges
159
+
160
+ ## append to tree
161
+ cvo.append(new_cvo)
162
+ parent_idx = expanded[exp_idx] # exp_idx is assumed sorted
163
+ parents.append(parent_idx)
164
+ added_len = exp_idx.shape[0]
165
+ idx_range.append(np.full((added_len, 2), -1, dtype=idx_range.dtype))
166
+
167
+ ## backtrack obj values
168
+ update_obj(expanded, parents.array, cvo.array, idx_range.array)
169
+
170
+ def rebuild(self, min_obj: float) -> None:
171
+ """Remove childless nodes and nodes with objectives below `min_obj`."""
172
+
173
+ parent = self.parent
174
+ idx_range = self.idx_range
175
+ cvo = self.cvo
176
+
177
+ n_removed = rebuild(cvo.array, idx_range.array, parent.array, min_obj)
178
+ for arr in (cvo, parent, idx_range):
179
+ arr.length -= n_removed
@@ -0,0 +1,406 @@
1
+ """A collection of functions which are jit compiled using numba."""
2
+
3
+ import numpy as np
4
+ from numba import njit, prange
5
+
6
+
7
+ @njit(parallel=True, nogil=True, cache=True)
8
+ def rebuild(cvo, idx_range, parents, min_obj) -> int:
9
+ """
10
+ Remove nodes whose objective falls to `min_obj` or below, and repair the indices of those remaining.
11
+
12
+ Surviving nodes are shifted down in-place, so the caller must reduce its own record of the array lengths by the returned count.
13
+
14
+ Args:
15
+ cvo: Node data of shape `(num_nodes,)` with `comp`, `value` and `obj` fields. Modified in-place.
16
+ idx_range: Child index ranges of shape `(num_nodes, 2)`. Modified in-place.
17
+ parents: Parent node indices of shape `(num_nodes,)`. Modified in-place.
18
+ min_obj: Nodes whose objective does not exceed this value are removed.
19
+
20
+ Returns:
21
+ Number of nodes removed.
22
+ """
23
+
24
+ ## find indices to remove
25
+ o = cvo["obj"]
26
+ removed_idx = (o <= min_obj).nonzero()[0]
27
+
28
+ ## prune tree
29
+ delete(cvo, removed_idx)
30
+ delete(parents, removed_idx)
31
+ delete(idx_range, removed_idx)
32
+
33
+ ## shift index values down
34
+ for i in prange(cvo.shape[0] - removed_idx.shape[0]):
35
+ p_offset = np.searchsorted(removed_idx, parents[i])
36
+ parents[i] -= p_offset
37
+
38
+ st, end = idx_range[i]
39
+ if st == end:
40
+ continue
41
+ else:
42
+ st_offset = np.searchsorted(removed_idx, st)
43
+ end_offset = np.searchsorted(removed_idx[st_offset:], end)
44
+ idx_range[i] = st - st_offset, end - end_offset - st_offset
45
+
46
+ return removed_idx.shape[0]
47
+
48
+
49
+ @njit(nogil=True, cache=True)
50
+ def query(x, cvo, idx_range, first, min_obj=-np.inf):
51
+ """
52
+ Query polyblock tree for all vertices `v` such that `v >= x` and `obj[v] >= min_obj`.
53
+
54
+ Args:
55
+ x: Query point of shape `(dim,)`.
56
+ cvo: Node data with `comp`, `value` and `obj` fields.
57
+ idx_range: Child index ranges of shape `(num_nodes, 2)`.
58
+ first: Root vertex value of shape `(dim,)`.
59
+ min_obj: Subtrees whose objective falls below this value are not descended into.
60
+
61
+ Returns:
62
+ A tuple `(values, indices)`:
63
+ values: Matching leaf vertices of shape `(num_leaves, dim)`.
64
+ indices: Indices of those leaves in `cvo`, of shape `(num_leaves,)`.
65
+ """
66
+
67
+ idx_type = idx_range.dtype.type
68
+ node_stack = [
69
+ (idx_type(0), first),
70
+ ]
71
+ leaf_idx = []
72
+ leaf_values = []
73
+
74
+ while node_stack:
75
+ ## search top node
76
+ node_idx, node_val = node_stack.pop()
77
+ st, end = idx_range[node_idx]
78
+
79
+ if st == -1:
80
+ node_idx = idx_range.dtype.type(node_idx)
81
+ leaf_idx.append(node_idx)
82
+ leaf_values.append(node_val)
83
+
84
+ for i in range(st, end):
85
+ ci = cvo[i]
86
+ if ci["obj"] >= min_obj and x[ci["comp"]] <= ci["value"]:
87
+ child_val = node_val.copy()
88
+ child_val[ci["comp"]] = ci["value"]
89
+ node_stack.append((i, child_val))
90
+
91
+ ## collect leaf values
92
+ l_idx = np.array(leaf_idx, dtype=idx_type)
93
+ n_leaves = l_idx.shape[0]
94
+ l_vals = np.empty((n_leaves, x.shape[0]), dtype=x.dtype)
95
+ for i in range(n_leaves):
96
+ l_vals[i] = leaf_values[i]
97
+
98
+ return l_vals, l_idx
99
+
100
+
101
+ @njit(parallel=True, nogil=True, cache=True)
102
+ def query_multi(x_batch, cvo, idx_range, first, lower, min_obj=-np.inf, delta=1e-3):
103
+ """
104
+ Query and refine a polyblock tree using a batch of points `x_batch`.
105
+
106
+ Each point is queried independently, and the leaves it matches are refined along every component which yields a non-redundant vertex.
107
+ A leaf matching more than one query point is refined against only the first of them.
108
+
109
+ Args:
110
+ x_batch: Query points of shape `(num_points, dim)`.
111
+ cvo: Node data with `comp`, `value` and `obj` fields.
112
+ idx_range: Child index ranges of shape `(num_nodes, 2)`.
113
+ first: Root vertex value of shape `(dim,)`.
114
+ lower: Component-wise lower-bounds on tree vertices, of shape `(dim,)`.
115
+ min_obj: Subtrees whose objective falls below this value are not descended into.
116
+ delta: Minimum separation from the query point required to refine a leaf.
117
+
118
+ Returns:
119
+ A tuple `(values, indices, vect, comp, cval)`:
120
+ values: Refined leaf vertices of shape `(num_refined, dim)`.
121
+ indices: Indices of those leaves in `cvo`, of shape `(num_refined,)`.
122
+ vect: Index into `values` of the parent leaf of each new node.
123
+ comp: Component reduced by each new node.
124
+ cval: New value taken by that component.
125
+ """
126
+
127
+ b = x_batch.shape[0]
128
+ idx_dtype = idx_range.dtype
129
+ float_dtype = x_batch.dtype
130
+ x_batch_delta = x_batch + delta
131
+
132
+ indices = [np.empty(0, dtype=idx_dtype) for _ in range(b)]
133
+ values = [np.empty((0, 0), dtype=float_dtype) for _ in range(b)]
134
+ vects = [np.empty((0), dtype=np.int64) for _ in range(b)]
135
+ comps = [np.empty((0), dtype=np.int64) for _ in range(b)]
136
+ comp_val = [np.empty((0), dtype=float_dtype) for _ in range(b)]
137
+
138
+ ## parallel queries and redundancy checks
139
+ for i in prange(b):
140
+ x = x_batch[i]
141
+ x_delta = x_batch_delta[i]
142
+ value, index = query(x, cvo, idx_range, first, min_obj)
143
+
144
+ ## only explore vertices further than delta and break ties
145
+ refine_mask = all_row(value > x_delta)
146
+ if i > 0:
147
+ for idx in range(value.shape[0]):
148
+ if refine_mask[idx]:
149
+ v_idx = value[idx]
150
+ for j in range(i):
151
+ x_delta_j = x_batch_delta[j]
152
+ feas_j = (v_idx > x_delta_j).all()
153
+ if feas_j:
154
+ refine_mask[idx] = False
155
+ break
156
+
157
+ idx_mask = x >= lower
158
+ vect, comp = find_redundant(value, idx_mask, refine_mask)
159
+
160
+ indices[i] = index[refine_mask]
161
+ values[i] = value[refine_mask]
162
+ vects[i] = vect
163
+ comps[i] = comp
164
+ comp_val[i] = x[comp]
165
+
166
+ ## merge all data while shifting indices
167
+ v_full = cat(values)
168
+ ind_full = cat(indices)
169
+
170
+ cumsum = len(indices[0])
171
+ for i in range(1, b):
172
+ vects[i] += cumsum
173
+ cumsum += len(indices[i])
174
+
175
+ vect_full = cat(vects)
176
+ comp_full = cat(comps)
177
+ cval_full = cat(comp_val)
178
+
179
+ return v_full, ind_full, vect_full, comp_full, cval_full
180
+
181
+
182
+ @njit(nogil=True, cache=True)
183
+ def cat(list_of_arrays):
184
+ """Concatenate list of arrays along first axis."""
185
+
186
+ size = sum([arr.shape[0] for arr in list_of_arrays])
187
+ ar0 = list_of_arrays[0]
188
+ combined = np.empty((size,) + ar0.shape[1:], dtype=ar0.dtype)
189
+ init_pos = 0
190
+ for arr in list_of_arrays:
191
+ combined[init_pos : init_pos + len(arr)] = arr
192
+ init_pos += len(arr)
193
+ return combined
194
+
195
+
196
+ @njit(nogil=True, cache=True, parallel=True, inline="always")
197
+ def all_row(arr):
198
+ """Equivalent to `np.all(arr, axis=1)`"""
199
+ rows = arr.shape[0]
200
+ mask = np.empty(rows, dtype=np.bool)
201
+ for i in prange(rows):
202
+ mask[i] = arr[i].all()
203
+ return mask
204
+
205
+
206
+ @njit(parallel=True, nogil=True, cache=True)
207
+ def find_best(cvo, idx_range, first, num=1):
208
+ """
209
+ Find up to `num` distinct leaf vertices, the first of which has the best objective.
210
+
211
+ Attempts to find distinct leaves by performing `num` tree descents.
212
+ Descent `i` carries an offset of `i` which diverts its path: at a node with `n` children it steps up to `n - 1` ranks below the best child, taking as many as the remaining offset allows and deducting them from it.
213
+ Once the offset reaches zero the descent follows best children the rest of the way down.
214
+ Descent `0` is never diverted and so reaches the best leaf, while larger offsets are spent as high in the tree as possible, giving paths that diverge earlier and hence distinct leaves.
215
+ A descent `i` still holding offset when it reaches a leaf is discarded as its path is identical to that of a descent with a smaller index `j < i`, so fewer than `num` vertices may be returned.
216
+
217
+ Args:
218
+ cvo: Node data with `comp`, `value` and `obj` fields.
219
+ idx_range: Child index ranges of shape `(num_nodes, 2)`.
220
+ first: Root vertex value of shape `(dim,)`.
221
+ num: Number of descents to attempt.
222
+
223
+ Returns:
224
+ Leaf vertices of shape `(num_found, dim)`, where `num_found <= num`.
225
+ The first leaf has the best objective.
226
+ """
227
+
228
+ dim = first.shape[0]
229
+ values = np.empty((num, dim), dtype=first.dtype)
230
+ for i in range(num):
231
+ values[i] = first
232
+
233
+ curr_idx = np.zeros(num, dtype=idx_range.dtype)
234
+ skipped_mask = np.zeros(num, dtype=np.bool)
235
+ for i in prange(num):
236
+ skip = np.int32(i)
237
+ while True:
238
+ st, end = idx_range[curr_idx[i]]
239
+ if st == end:
240
+ if skip == 0:
241
+ skipped_mask[i] = True
242
+ break
243
+ order = end - st - 1
244
+ if skip > 0:
245
+ less = min(skip, end - st - 1)
246
+ order -= less
247
+ skip -= less
248
+
249
+ o = cvo[st:end]["obj"]
250
+ chosen_child = np.argpartition(o, order)[order] + st
251
+ child_cvo = cvo[chosen_child]
252
+ values[i][child_cvo["comp"]] = child_cvo["value"]
253
+ curr_idx[i] = chosen_child
254
+
255
+ return values[skipped_mask]
256
+
257
+
258
+ @njit(parallel=True, nogil=True, cache=True)
259
+ def new_block(block, added, idx_mask, delta=1e-3):
260
+ """
261
+ Remove infeasible cone given by `added` from `block`.
262
+
263
+ Args:
264
+ block: Current polyblock vertices of shape `(num_vertices, dim)`.
265
+ added: Vertex of the cone to remove, of shape `(dim,)`.
266
+ idx_mask: Components eligible for reduction, of shape `(dim,)`.
267
+ delta: Minimum separation from `added` required for a vertex to be cut.
268
+
269
+ Returns:
270
+ A tuple `(removed_idx, new_vertices)`:
271
+ removed_idx: Indices of vertices in `block` refined by the cut.
272
+ new_vertices: New vertices generated by the cut.
273
+ """
274
+
275
+ ## perform range query
276
+ vertex_idx = []
277
+ old_size, dims = block.shape
278
+ for i in range(old_size):
279
+ row = block[i]
280
+ inside = True
281
+ for d in range(dims):
282
+ if added[d] > row[d]:
283
+ inside = False
284
+ break
285
+ if inside:
286
+ vertex_idx.append(i)
287
+
288
+ vertex_idx = np.array(vertex_idx, dtype=np.int64)
289
+ vertices = block[vertex_idx]
290
+ removed_mask = all_row(vertices > added + delta)
291
+
292
+ ## compute new vertices
293
+ vect, comp = find_redundant(vertices, idx_mask, removed_mask)
294
+ apen = vertices[removed_mask][vect]
295
+ for i in prange(apen.shape[0]):
296
+ c_comp = comp[i]
297
+ apen[i, c_comp] = added[c_comp]
298
+
299
+ return vertex_idx[removed_mask], apen
300
+
301
+
302
+ @njit(nogil=True, cache=True)
303
+ def delete(array, removed_idx):
304
+ """Delete indices in-place by shifting remaining elements down. This function also sorts removed_idx."""
305
+
306
+ ## check if removed_idx is sorted
307
+ r_prev = -np.inf
308
+ for r in removed_idx:
309
+ if r >= r_prev:
310
+ r_prev = r
311
+ else:
312
+ removed_idx.sort()
313
+ break
314
+
315
+ ## shift down
316
+ n_removed = removed_idx.shape[0]
317
+ old_size = array.shape[0]
318
+ for down_shift in range(n_removed + 1):
319
+ st = removed_idx[down_shift - 1] + 1 if down_shift > 0 else 0
320
+ end = removed_idx[down_shift] if down_shift < n_removed else old_size
321
+ for j in range(st, end):
322
+ array[j - down_shift] = array[j]
323
+
324
+
325
+ @njit(parallel=True, nogil=True, inline="always", cache=True)
326
+ def find_redundant(arr, idx_mask, eps_mask=None):
327
+ """
328
+ Find the vertex refinements which are dominated by another vertex in `arr`.
329
+
330
+ Reducing component `d` of vertex `arr[i]` is redundant when some other vertex `arr[j]` already dominates the result, which happens exactly when `arr[i]` exceeds `arr[j]` in dimension `d` alone.
331
+
332
+ Args:
333
+ arr: Candidate vertices of shape `(num_vertices, dim)`.
334
+ idx_mask: Components eligible for reduction, of shape `(dim,)`.
335
+ eps_mask: Optional mask of shape `(num_vertices,)` selecting the vertices to refine. All vertices are refined when omitted.
336
+
337
+ Returns:
338
+ A tuple `(vect, comp)` of equal length, holding one entry per non-redundant refinement:
339
+ vect: Index of the vertex to refine, relative to the rows selected by `eps_mask`.
340
+ comp: Component of that vertex to reduce.
341
+ """
342
+
343
+ if eps_mask is None:
344
+ eps_idx = np.arange(arr.shape[0])
345
+ else:
346
+ eps_idx = eps_mask.nonzero()[0]
347
+
348
+ n_exp = eps_idx.shape[0]
349
+ dims = arr.shape[1]
350
+ mask = np.empty((n_exp, dims), dtype=np.bool)
351
+ for i in prange(n_exp):
352
+ mask[i] = idx_mask
353
+
354
+ for i in prange(n_exp):
355
+ ai = arr[eps_idx[i]]
356
+ for aj in arr:
357
+ fail_i = -1
358
+ nfail_i = 0
359
+
360
+ for d in range(dims):
361
+ dom_ij = ai[d] > aj[d]
362
+
363
+ if dom_ij:
364
+ nfail_i += 1
365
+ fail_i = d
366
+ if nfail_i > 1:
367
+ break
368
+
369
+ if nfail_i == 1:
370
+ mask[i, fail_i] = False
371
+
372
+ vect, comp = mask.nonzero()
373
+ return vect, comp
374
+
375
+
376
+ @njit(nogil=True, cache=True)
377
+ def update_obj(expanded, parents, cvo, idx_range):
378
+ """
379
+ Update the objective attribute of polyblock tree by propagating the maximum objective value from children to parents.
380
+
381
+ Args:
382
+ expanded: Indices of the nodes whose children have changed.
383
+ parents: Parent node indices of shape `(num_nodes,)`.
384
+ cvo: Node data with `comp`, `value` and `obj` fields. The `obj` field is modified in-place.
385
+ idx_range: Child index ranges of shape `(num_nodes, 2)`.
386
+ """
387
+
388
+ curr_layer = expanded
389
+ while curr_layer.shape[0] > 0:
390
+ layer_mask = np.zeros_like(curr_layer, dtype=np.bool)
391
+
392
+ for i in range(curr_layer.shape[0]):
393
+ ## get child data
394
+ curr_idx = curr_layer[i]
395
+ st, end = idx_range[curr_idx]
396
+
397
+ ## update self if best obj changes
398
+ best_obj = cvo[st:end]["obj"].max() if st != end else -np.inf
399
+ if best_obj < cvo[curr_idx]["obj"]:
400
+ cvo[curr_idx]["obj"] = best_obj
401
+ if curr_idx > 0:
402
+ layer_mask[i] = True
403
+
404
+ ## find unique set of parents
405
+ curr_layer = parents[curr_layer[layer_mask]]
406
+ curr_layer = np.unique(curr_layer)
polyblocks/naive.py ADDED
@@ -0,0 +1,65 @@
1
+ import numpy as np
2
+ from numpy.typing import NDArray
3
+
4
+ from .abstract import ABPolyblock
5
+ from .containers import DynamicArray
6
+ from .jit_funcs import new_block
7
+
8
+
9
+ class BasePOA(ABPolyblock):
10
+ """A naive implementation of POA which directly stores polyblock vertices in dynamic arrays."""
11
+
12
+ POLYBLOCK_LIMIT = 3 * int(1e6)
13
+ RHO = 0.2
14
+
15
+ def __init__(self, lower, upper):
16
+ self.lower = lower
17
+ const = self.RHO / (1 - self.RHO)
18
+ self.lower_offset = lower - const * (upper - lower).max()
19
+ self.lower_offset = self.lower_offset[None]
20
+ self.new: NDArray
21
+
22
+ self.vertices = DynamicArray(dim=lower.shape[0], dtype=lower.dtype)
23
+ self.obj_vals = DynamicArray(dim=1, dtype=lower.dtype)
24
+ self.vertices.append(upper)
25
+ self.obj_vals.append(np.inf)
26
+
27
+ def projection_pairs(self) -> tuple[NDArray, NDArray]:
28
+ best_vtx = self.obj_vals.array.argmax()
29
+ return self.lower_offset, self.vertices[None, best_vtx].copy()
30
+
31
+ def set_min_obj(self, obj) -> None:
32
+ removed_idx = (self.obj_vals.array < obj).nonzero()[0]
33
+ self.vertices.delete(removed_idx)
34
+ self.obj_vals.delete(removed_idx)
35
+
36
+ def new_vertices(self, proj, delta) -> NDArray:
37
+ proj = proj.flatten()
38
+ idx_mask = proj >= self.lower
39
+ vertices = self.vertices
40
+ obj_vals = self.obj_vals
41
+
42
+ removed_idx, self.new = new_block(vertices.array, proj, idx_mask, delta)
43
+ vertices.delete(removed_idx)
44
+ obj_vals.delete(removed_idx)
45
+ return self.new
46
+
47
+ def update(self, new_mask, new_obj) -> bool:
48
+ new = self.new[new_mask]
49
+ self.vertices.append(new)
50
+ self.obj_vals.append(new_obj)
51
+ return self.vertices.length == 0
52
+
53
+ @property
54
+ def size(self):
55
+ return self.vertices.length
56
+
57
+
58
+ class BalancedPOA(BasePOA):
59
+ """A variant of the naive POA implementation which uses balanced anchors."""
60
+
61
+ def projection_pairs(self) -> tuple[NDArray, NDArray]:
62
+ blk_best = self.obj_vals.array.argmax()
63
+ best_upper = self.vertices[None, blk_best]
64
+ best_lower = best_upper - (best_upper - self.lower).max()
65
+ return best_lower, best_upper.copy()
polyblocks/py.typed ADDED
File without changes
polyblocks/tree.py ADDED
@@ -0,0 +1,91 @@
1
+ import numpy as np
2
+
3
+ from .abstract import ABPolyblock
4
+ from .containers import Tree
5
+
6
+
7
+ class TreePOA(ABPolyblock):
8
+ """
9
+ An implementation of POA which stores polyblock vertices in a tree.
10
+
11
+ Rather than storing the current vertices `V` alone, this solver keeps the whole tree of refinements the search has produced, with `V` as its leaves.
12
+ The representation is compressed: a cut replaces a vertex by copies differing from it in one component, so a node need only record the component its cut modified and that component's new value, with the root holding the initial upper point.
13
+ A vertex is recovered by descending from the root and tracing these modifications at each node.
14
+
15
+ In return, each node bounds both the region and objective of its subtree, so a search can rule out the whole subtree without visiting its leaves. POA's searches over `V` thus become descents rather than scans:
16
+
17
+ - `projection_pairs` finds the maximal vertex by descending through the highest-objective child at each
18
+ step, using descents diverted into lower-ranked children to return up to `PROJECTED_VERTICES`
19
+ distinct leaves
20
+ - `new_vertices` collects the vertices above a projection by recursing from the root, entering a child
21
+ only when its component clears the projection and its objective attribute clears the incumbent.
22
+ - `best_bound` is read off the root, whose objective attribute is the maximum over all vertices.
23
+
24
+ Under a positive `delta` the depth of the tree is bounded independently of how many vertices it holds, so a descent costs `O(dim * depth)` against the `O(len(V))` of a flat array.
25
+
26
+ Pruned nodes are not removed eagerly, as repairing every index is expensive for large trees.
27
+ Instead, their objective attributes are used to keep them out of both tree traversals for free.
28
+ `update` rebuilds the tree every `REBUILD_GAP` updates, removing childless nodes and those which can no longer improve the incumbent.
29
+
30
+ Attributes:
31
+ REBUILD_GAP: Number of updates between tree rebuilds, which prunes redundant nodes.
32
+ PROJECTED_VERTICES: Maximum number of vertices projected per iteration.
33
+ """
34
+
35
+ REBUILD_GAP: int = 10000
36
+ PROJECTED_VERTICES: int = 8
37
+
38
+ def __init__(self, lower, upper) -> None:
39
+ self.lower = lower
40
+ self.tree = Tree(upper)
41
+ self.best_obj = -np.inf
42
+ self.clean_counter = 0
43
+
44
+ self.expanded_idx: np.ndarray
45
+ self.vect: np.ndarray
46
+ self.comp: np.ndarray
47
+ self.cval: np.ndarray
48
+
49
+ def projection_pairs(self) -> tuple[np.ndarray, np.ndarray]:
50
+ vertices = self.tree.find_best(self.PROJECTED_VERTICES)
51
+ anchors = vertices - (vertices - self.lower).max(-1, keepdims=True)
52
+ return anchors, vertices
53
+
54
+ def set_min_obj(self, obj) -> None:
55
+ self.best_obj = obj
56
+
57
+ def new_vertices(self, proj, delta) -> np.ndarray:
58
+ ## query range using tree search
59
+ (expanded_data, self.expanded_idx, vect, comp, cval) = self.tree.query(
60
+ proj, self.lower, min_obj=self.best_obj, delta=delta
61
+ )
62
+
63
+ ## compute new vertices
64
+ new_data = expanded_data[vect]
65
+ np.put_along_axis(new_data, comp[:, None], cval[:, None], axis=1)
66
+
67
+ self.vect, self.comp = vect, comp
68
+ self.cval = cval
69
+ return new_data
70
+
71
+ def update(self, new_mask, new_obj) -> bool:
72
+ vect, comp = self.vect[new_mask], self.comp[new_mask]
73
+ cval = self.cval[new_mask]
74
+ self.tree.add(self.expanded_idx, vect, comp, cval, new_obj)
75
+
76
+ ## rebuild tree periodically
77
+ if self.clean_counter == self.REBUILD_GAP:
78
+ self.tree.rebuild(self.best_obj)
79
+ self.clean_counter = 0
80
+ else:
81
+ self.clean_counter += 1
82
+
83
+ return self.best_bound <= self.best_obj
84
+
85
+ @property
86
+ def size(self):
87
+ return self.tree.cvo.length
88
+
89
+ @property
90
+ def best_bound(self) -> float:
91
+ return self.tree.cvo[0]["obj"].item()
polyblocks/utils.py ADDED
@@ -0,0 +1,101 @@
1
+ from collections.abc import Callable
2
+
3
+ import numpy as np
4
+ from numpy.linalg import norm
5
+ from numpy.typing import NDArray
6
+
7
+
8
+ def monotone_proj(
9
+ vertices: NDArray,
10
+ anchors: NDArray,
11
+ oracle: Callable,
12
+ eps=1e-4,
13
+ delta=0.0,
14
+ ):
15
+ """
16
+ Perform a vectorised bisection search to compute monotone projections onto the `delta`-eroded normal set corresponding to the given oracle.
17
+
18
+ Args:
19
+ vertices: Vertices to project using paired anchors, of shape `(num_pairs, dim)`.
20
+ anchors: Feasible anchor points for projecting paired vertex, of shape `(num_pairs, dim)`. Shifted by `delta` to account for erosion.
21
+ oracle: An oracle for querying normal set feasibility.
22
+ eps: The numerical tolerance for the line search.
23
+ delta: The erosion factor for the normal set.
24
+
25
+ Returns:
26
+ Batched monotone projections of shape `(num_pairs, dim)`.
27
+ """
28
+
29
+ ## resolve erosion
30
+ vert_offset = vertices + delta
31
+ anchors = anchors - delta
32
+
33
+ ## scale epsilon
34
+ diff = anchors - vertices
35
+ eps = eps / norm(diff, ord=2, axis=-1, keepdims=True)
36
+
37
+ ub = np.ones_like(eps)
38
+ lb = -eps
39
+ x = np.full_like(eps, 0.5)
40
+
41
+ while (ub - lb > eps).any():
42
+ mask = oracle(diff * x + vert_offset)
43
+ n_mask = ~mask
44
+ ub[n_mask] = x[n_mask]
45
+ lb[mask] = x[mask]
46
+ x = (ub + lb) / 2
47
+
48
+ return lb * diff + vertices
49
+
50
+
51
+ def tighten(root: NDArray, reduced: NDArray, oracle: Callable, eps=1e-4):
52
+ """Tighten the box anchored at `root` using a monotone oracle."""
53
+
54
+ reduced = reduced.copy()
55
+ for ind in range(reduced.shape[-1]):
56
+ c_end = root.copy()
57
+ c_end[ind] = reduced[ind]
58
+ reduced[ind] = monotone_proj(root[None], c_end[None], oracle, eps=eps)[0, ind]
59
+ return reduced
60
+
61
+
62
+ def _center(s: str, width: int) -> str:
63
+ pad = max(width - len(s), 0)
64
+ left = pad - pad // 2
65
+ return " " * left + s + " " * (pad - left)
66
+
67
+
68
+ def print_row(*, header: bool = True, **columns):
69
+ """
70
+ Print a row of a progress table, drawing a header/rule above it if `header` is set.
71
+
72
+ Args:
73
+ header: Whether to (re)print the column header and box rules before the row.
74
+ **columns: Column name -> value. Column names are converted to title case for display,
75
+ and the column width is derived from the label length so header and rows always align.
76
+ """
77
+
78
+ labels = {name: name.replace("_", " ").title() for name in columns}
79
+ widths = {name: max(len(label) + 2, 8) for name, label in labels.items()}
80
+
81
+ if header:
82
+ print("┌" + "┬".join("─" * w for w in widths.values()) + "┐")
83
+ print(
84
+ "│"
85
+ + "│".join(_center(label, widths[name]) for name, label in labels.items())
86
+ + "│"
87
+ )
88
+ print("├" + "┼".join("─" * w for w in widths.values()) + "┤")
89
+
90
+ cells = []
91
+ for name, value in columns.items():
92
+ if isinstance(value, int):
93
+ value = f"{value:#.2g}"
94
+ elif isinstance(value, float):
95
+ value = f"{value:#.4g}"
96
+ else:
97
+ value = str(value)
98
+
99
+ cells.append(_center(value, widths[name]))
100
+
101
+ print("│" + "│".join(cells) + "│")
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: polyblocks
3
+ Version: 0.1.0
4
+ Summary: Solvers for global monotonic optimisation problems
5
+ Author: Ahmed Rashwan
6
+ Author-email: Ahmed Rashwan <ar3009@bath.ac.uk>
7
+ License-Expression: MIT
8
+ Requires-Dist: numba>=0.65.1
9
+ Requires-Dist: numpy>=2.2.6
10
+ Requires-Python: >=3.10
11
+ Project-URL: Homepage, https://github.com/RashwanA/polyblocks
12
+ Description-Content-Type: text/markdown
13
+
14
+ Polyblocks
15
+ =============
16
+
17
+ This package provides a set of solvers for monotonic optimisation problems, which take the general form:
18
+
19
+ $$
20
+ \begin{aligned}
21
+ \max_{x \in \mathbb{R}^n} \quad & f(x) \\
22
+ \text{s.t.} \ \quad & x_l \le x \le x_u \\
23
+ & g(x) \le 0 \\
24
+ & h(x) \ge 0
25
+ \end{aligned}
26
+ $$
27
+
28
+ where $f$, $g$, and $h$ are non-decreasing in each coordinate.
29
+ This covers a broad class of non-convex problems, including polynomial programming along with many radio resource allocation problems in communications.
30
+
31
+ Solvers are built around the **Polyblock Outer-approximation (POA)** algorithm: a branch-and-bound algorithm which iteratively refines a rectangular outer-approximation of the solution space.
32
+ Full details are available in the accompanying paper: **LINK**
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install polyblocks
38
+ ```
39
+
40
+ or, for local development:
41
+
42
+ ```bash
43
+ git clone https://github.com/RashwanA/polyblocks.git
44
+ cd polyblocks
45
+ pip install .
46
+ ```
47
+
48
+ ## Solvers
49
+
50
+ The package includes three built-in solvers: `BasePOA`, `BalancedPOA`, and `TreePOA`.
51
+ Of these `TreePOA` typically has the best empirical performance as it uses an efficient tree-based representation of the solution space.
52
+ All solvers implement the common `ABPolyblock` interface, which exposes a set of subroutines used by the POA algorithm.
53
+ Users may also define custom solvers by implementing this interface.
54
+
55
+ ## Usage
56
+
57
+ Each solver is called via its `solve` classmethod, which takes the objective, the bounding box, and oracles for querying feasibility.
58
+
59
+ Ball optimisation example:
60
+
61
+ ```python
62
+ from polyblocks import TreePOA
63
+
64
+ # maximise 3x + 4y
65
+ # subject to x^2 + y^2 <= 1
66
+
67
+ def obj(x):
68
+ return x @ [3, 4]
69
+
70
+ def ub_oracle(x):
71
+ return (x ** 2).sum(-1) <= 1
72
+
73
+ sol = TreePOA.solve(
74
+ obj=obj,
75
+ ub_oracle=ub_oracle,
76
+ x_l=(0., 0.),
77
+ x_u=(1., 1.),
78
+ )
79
+
80
+ print(f"status={sol.status}, obj={sol.obj:.2f}, x={sol.x.round(2)}")
81
+ ```
82
+
83
+ Geometric programming example:
84
+
85
+ ```python
86
+ from polyblocks import TreePOA
87
+
88
+ # maximise x y z
89
+ # subject to x (y + z) <= 4,
90
+ # y z <= 4,
91
+ # x, y, z ∈ [0, 4]
92
+
93
+ def obj(x):
94
+ return x.prod(1)
95
+
96
+ def ub_oracle(x):
97
+ cons1 = x[:,0] * (x[:,1:].sum(1)) <= 4.
98
+ cons2 = x[:,1:].prod(1) <= 4.
99
+ return cons1 * cons2
100
+
101
+ sol = TreePOA.solve(
102
+ obj=obj,
103
+ ub_oracle=ub_oracle,
104
+ x_l=(0., 0., 0.),
105
+ x_u=4., # bounds are broadcast
106
+ verbose_gap=10, # print progress every 10 iterations
107
+ )
108
+ print(f"status={sol.status}, obj={sol.obj:.2f}, x={sol.x.round(2)}")
109
+ ```
110
+
111
+ Objectives and oracles are expected to accept batched inputs of shape `(num_points, dim)`.
@@ -0,0 +1,11 @@
1
+ polyblocks/__init__.py,sha256=5DkiX8mAIV99j_XIC_0dxP9FiHOexVIaDgDuLitOcQM,186
2
+ polyblocks/abstract.py,sha256=cBUIBB9RVHLCtAnnPkNSTmqcMEsS0pjRueFLHVrIWIM,14050
3
+ polyblocks/containers.py,sha256=1kr-g5_Jb36nv9KcFCN_58GjERNo_iBa7gM7aNacs2Y,6081
4
+ polyblocks/jit_funcs.py,sha256=qKyre3qkSSGw8DKfl2NS7yqdQXQoWlC0dvF1bvTWuKQ,14422
5
+ polyblocks/naive.py,sha256=2oxsMOd7PPt_o3Wgwc5cnM_kAyifSl1nYCrHnVHjBYM,2184
6
+ polyblocks/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ polyblocks/tree.py,sha256=lSXF9iUjfGDGEm0-Cpx2SlmaeuTn8Z6_drBeIBcPY9Q,3974
8
+ polyblocks/utils.py,sha256=hndB92ZppZoA9R7gdM5fDyMm8f47KnvQ-H3xps0lJ8s,3188
9
+ polyblocks-0.1.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
10
+ polyblocks-0.1.0.dist-info/METADATA,sha256=3C9mNfVLah1-EUrpdHpjDXR387fHAmkIXQmnwegdiCQ,2958
11
+ polyblocks-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any