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 +5 -0
- polyblocks/abstract.py +301 -0
- polyblocks/containers.py +179 -0
- polyblocks/jit_funcs.py +406 -0
- polyblocks/naive.py +65 -0
- polyblocks/py.typed +0 -0
- polyblocks/tree.py +91 -0
- polyblocks/utils.py +101 -0
- polyblocks-0.1.0.dist-info/METADATA +111 -0
- polyblocks-0.1.0.dist-info/RECORD +11 -0
- polyblocks-0.1.0.dist-info/WHEEL +4 -0
polyblocks/__init__.py
ADDED
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
|
polyblocks/containers.py
ADDED
|
@@ -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
|
polyblocks/jit_funcs.py
ADDED
|
@@ -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,,
|