pulp-lparray 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulp_lparray-2.0.0/.gitignore +15 -0
- pulp_lparray-2.0.0/LICENSE +16 -0
- pulp_lparray-2.0.0/PKG-INFO +176 -0
- pulp_lparray-2.0.0/README.md +165 -0
- pulp_lparray-2.0.0/pulp_lparray/__init__.py +3 -0
- pulp_lparray-2.0.0/pulp_lparray/lparray.py +373 -0
- pulp_lparray-2.0.0/pulp_lparray/py.typed +0 -0
- pulp_lparray-2.0.0/pyproject.toml +60 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT No Attribution
|
|
2
|
+
|
|
3
|
+
Copyright 2019-2026 Evgeny Naumov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
|
6
|
+
software and associated documentation files (the "Software"), to deal in the Software
|
|
7
|
+
without restriction, including without limitation the rights to use, copy, modify,
|
|
8
|
+
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
9
|
+
permit persons to whom the Software is furnished to do so.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
12
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
13
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
14
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
15
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
16
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pulp-lparray
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: PuLP x NumPy
|
|
5
|
+
License-Expression: MIT-0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: numpy>=2
|
|
9
|
+
Requires-Dist: pulp>=4
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# pulp-lparray
|
|
13
|
+
|
|
14
|
+
*PuLP x NumPy, a match made in heaven.*
|
|
15
|
+
|
|
16
|
+
An `lparray` is a NumPy array of [PuLP](https://github.com/coin-or/pulp) variables, affine
|
|
17
|
+
expressions or constraints. Broadcasting, indexing, sums and matrix products build whole
|
|
18
|
+
families of expressions at once; `<=`, `>=` and `==` build families of constraints; and a
|
|
19
|
+
handful of methods linearise abs, max, min, argmax, top-k, logic and piecewise-linear functions.
|
|
20
|
+
Never see a `for` or an index variable again.
|
|
21
|
+
|
|
22
|
+
It needs Python 3.12+ and PuLP 4. PuLP 4 ships no solver: `pip install "pulp[cbc]"` adds CBC.
|
|
23
|
+
|
|
24
|
+
## Super Sudoku
|
|
25
|
+
|
|
26
|
+
A Super Sudoku is a Sudoku in which the cells at the same position within their boxes also hold
|
|
27
|
+
distinct digits.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import numpy as np
|
|
31
|
+
from pulp import LpBinary, LpProblem
|
|
32
|
+
|
|
33
|
+
from pulp_lparray import lparray
|
|
34
|
+
|
|
35
|
+
clues = np.array(
|
|
36
|
+
[
|
|
37
|
+
[0, 4, 0, 0, 1, 8, 0, 0, 0],
|
|
38
|
+
[0, 5, 0, 0, 0, 2, 0, 0, 0],
|
|
39
|
+
[2, 0, 8, 0, 0, 0, 7, 1, 4],
|
|
40
|
+
[1, 8, 2, 0, 0, 0, 5, 6, 0],
|
|
41
|
+
[0, 6, 0, 0, 0, 0, 0, 0, 0],
|
|
42
|
+
[4, 7, 0, 0, 0, 6, 0, 0, 2],
|
|
43
|
+
[0, 0, 5, 0, 9, 7, 0, 0, 1],
|
|
44
|
+
[0, 0, 4, 0, 0, 0, 0, 0, 0],
|
|
45
|
+
[9, 0, 0, 0, 0, 0, 3, 0, 0],
|
|
46
|
+
]
|
|
47
|
+
) # 0 marks an empty cell
|
|
48
|
+
|
|
49
|
+
prob = LpProblem("super_sudoku")
|
|
50
|
+
# board[R, C, r, c, n] == 1: the cell in row 3R + r and column 3C + c holds digit n + 1
|
|
51
|
+
board = lparray.create(prob, "board", (3, 3, 3, 3, 9), cat=LpBinary)
|
|
52
|
+
(board.sum(axis=4) == 1).constrain(prob, "cell")
|
|
53
|
+
(board.sum(axis=(1, 3)) == 1).constrain(prob, "row")
|
|
54
|
+
(board.sum(axis=(0, 2)) == 1).constrain(prob, "column")
|
|
55
|
+
(board.sum(axis=(2, 3)) == 1).constrain(prob, "box")
|
|
56
|
+
(board.sum(axis=(0, 1)) == 1).constrain(prob, "position")
|
|
57
|
+
cells = board.transpose(0, 2, 1, 3, 4).reshape(9, 9, 9) # row, column, digit
|
|
58
|
+
row, col = clues.nonzero()
|
|
59
|
+
(cells[row, col, clues[row, col] - 1] == 1).constrain(prob, "clue")
|
|
60
|
+
prob.solve()
|
|
61
|
+
|
|
62
|
+
grid = cells.values.argmax(axis=2) + 1
|
|
63
|
+
assert (grid[row, col] == clues[row, col]).all()
|
|
64
|
+
assert all(sorted(line) == list(range(1, 10)) for line in [*grid, *grid.T])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Portfolio allocation
|
|
68
|
+
|
|
69
|
+
Whole numbers of shares whose values best match target weights within a budget, measured by
|
|
70
|
+
relative error so that every position counts alike. Minimising `deviation` subject to
|
|
71
|
+
`deviation >= error` and `deviation >= -error` makes it `|error|` without binaries; `abs` is for
|
|
72
|
+
magnitudes that the objective does not push down.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pulp import LpInteger, LpMinimize
|
|
76
|
+
|
|
77
|
+
price = np.array([31.0, 77.0, 12.5, 220.0])
|
|
78
|
+
weight = np.array([0.4, 0.3, 0.2, 0.1])
|
|
79
|
+
funds = 10_000
|
|
80
|
+
|
|
81
|
+
prob = LpProblem("allocation", LpMinimize)
|
|
82
|
+
shares = lparray.create(prob, "shares", 4, lowBound=0, cat=LpInteger)
|
|
83
|
+
(shares @ price <= funds).constrain(prob, "budget")
|
|
84
|
+
target = funds * weight / price
|
|
85
|
+
error = (shares - target) / target
|
|
86
|
+
deviation = lparray.create(prob, "deviation", 4)
|
|
87
|
+
(deviation >= error).constrain(prob, "over")
|
|
88
|
+
(deviation >= -error).constrain(prob, "under")
|
|
89
|
+
prob += deviation.sum().item()
|
|
90
|
+
prob.solve()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Facility logistics
|
|
94
|
+
|
|
95
|
+
Open exactly two facilities per region, ship every product to every customer, and pay an
|
|
96
|
+
operating cost with a volume discount on each facility's throughput. The discount makes the cost
|
|
97
|
+
concave, so `lp_interp` selects its segment with binaries.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
region = np.array([0, 0, 0, 1, 1, 1])
|
|
101
|
+
capacity = np.array([12, 6, 6, 12, 6, 6])
|
|
102
|
+
open_cost = np.array([10, 5, 7, 10, 5, 7])
|
|
103
|
+
ship_cost = np.array(
|
|
104
|
+
[
|
|
105
|
+
[2, 2, 3, 9, 9],
|
|
106
|
+
[3, 1, 2, 9, 9],
|
|
107
|
+
[2, 3, 1, 9, 9],
|
|
108
|
+
[9, 9, 9, 2, 2],
|
|
109
|
+
[9, 9, 9, 3, 1],
|
|
110
|
+
[9, 9, 9, 2, 3],
|
|
111
|
+
]
|
|
112
|
+
) # facility × customer
|
|
113
|
+
demand = np.array([[4, 2, 1], [0, 3, 2], [1, 1, 0], [3, 0, 1], [2, 2, 2]]) # customer × product
|
|
114
|
+
|
|
115
|
+
prob = LpProblem("logistics", LpMinimize)
|
|
116
|
+
is_open = lparray.create(prob, "open", 6, cat=LpBinary)
|
|
117
|
+
flow = lparray.create(prob, "flow", (6, 5, 3), lowBound=0)
|
|
118
|
+
(flow.sum(axis=0) == demand).constrain(prob, "demand")
|
|
119
|
+
throughput = flow.sum(axis=(1, 2))
|
|
120
|
+
(throughput <= capacity * is_open).constrain(prob, "capacity")
|
|
121
|
+
((region == np.arange(2)[:, None]) @ is_open == 2).constrain(prob, "two_per_region")
|
|
122
|
+
# The first 5 units cost 3 each, and the rest 2 each.
|
|
123
|
+
operating = throughput.lp_interp(prob, "operating", xp=[0, 5, 12], fp=[0, 15, 29])
|
|
124
|
+
prob += ((ship_cost[:, :, None] * flow).sum() + open_cost @ is_open + operating.sum()).item()
|
|
125
|
+
prob.solve()
|
|
126
|
+
|
|
127
|
+
assert (is_open.values.reshape(2, 3).sum(axis=1) == 2).all()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Formulations
|
|
131
|
+
|
|
132
|
+
Methods taking `(prob, name)` add auxiliary variables and constraints named `{name}_{role}`.
|
|
133
|
+
|
|
134
|
+
| method | result |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `x.abs(prob, name, bigM=…)` | `\|x\|` |
|
|
137
|
+
| `x.abs_decompose(prob, name, bigM=…)` | `(p, m)`: `p - m == x`, `p, m >= 0`, one of them zero |
|
|
138
|
+
| `x.logical_clip(prob, name, bigM=…)` | binary `min(x, 1)` of integral `x >= 0` |
|
|
139
|
+
| `x.lp_max(prob, name, axis=…, bigM=…)`, `lp_min` | maximum, minimum along `axis` |
|
|
140
|
+
| `x.lp_argmax(prob, name, axis=…, bigM=…)`, `lp_argmin` | one-hot indicator of an extremum along `axis` |
|
|
141
|
+
| `x.lp_topk(prob, name, k=…, axis=…, bigM=…)` | indicator of `k` largest entries along `axis` |
|
|
142
|
+
| `b.lp_bin_max(prob, name, axis=…)`, `lp_bin_min` | logical OR, AND of binary `b` along `axis` |
|
|
143
|
+
| `z.lp_bin_and(prob, name, *bs)`, `lp_bin_or` | constrains binary `z` to the AND, OR of `bs` |
|
|
144
|
+
| `x.lp_interp(prob, name, xp=…, fp=…)` | `np.interp(x, xp, fp)`, breakpoints broadcasting against `x` |
|
|
145
|
+
|
|
146
|
+
`bigM` must bound the spread of the values involved: `|x|` for `abs`, `x` for `logical_clip`,
|
|
147
|
+
and the range along `axis` for the rest. Too small a `bigM` silently cuts off solutions.
|
|
148
|
+
`lp_interp` constrains `x` to lie between the first and last breakpoints rather than clamping it.
|
|
149
|
+
|
|
150
|
+
## Semantics
|
|
151
|
+
|
|
152
|
+
`constrain(prob, name)` adds each constraint as `name(i,j,…)` and returns the array of
|
|
153
|
+
`LpConstraint`s that the problem stores.
|
|
154
|
+
|
|
155
|
+
Sums, `@` and `dot` take time linear in the contracted length. Other contractions, such as
|
|
156
|
+
`np.einsum` and `np.inner`, run NumPy's object loops, which chain PuLP's `+` and take quadratic
|
|
157
|
+
time. Reductions return 0-d lparrays, whose `item()` is the PuLP expression. Ufuncs that are not
|
|
158
|
+
affine, such as `np.maximum` and `np.abs`, raise `TypeError`; the methods above linearise them.
|
|
159
|
+
|
|
160
|
+
Keep the lparray on the left of `+` and `-` against a single PuLP variable or expression:
|
|
161
|
+
PuLP treats an array on its right as an iterable and sums it into one expression.
|
|
162
|
+
|
|
163
|
+
## Upgrading from 1.x
|
|
164
|
+
|
|
165
|
+
| 1.x | 2.0 |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `lparray.create(name, index_sets)`, `create_anon(name, shape)`, `create_like(name, a)` | `lparray.create(prob, name, shape)` |
|
|
168
|
+
| `x.sumit()` | `x.sum().item()` |
|
|
169
|
+
| `lp_int_max`, `lp_real_max`, `lp_int_min`, `lp_real_min` | `lp_max`, `lp_min` |
|
|
170
|
+
| `lp_bin_argmax`, `lp_bin_argmin`, `lp_bin_topk` | `lp_argmax`, `lp_argmin`, `lp_topk` |
|
|
171
|
+
| `piecewise_linear_sos2` | `lp_interp` |
|
|
172
|
+
| `abs_clip` | `abs` |
|
|
173
|
+
| `one_hot`, `constrain_one_hot`, `all_different`, `indicator_le`, `indicator_ge` | the sum or comparison each wrapped |
|
|
174
|
+
| `soft_select_max`, `count_out` | removed |
|
|
175
|
+
|
|
176
|
+
Every `bigM` is required, and `constrain` returns the stored constraints.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# pulp-lparray
|
|
2
|
+
|
|
3
|
+
*PuLP x NumPy, a match made in heaven.*
|
|
4
|
+
|
|
5
|
+
An `lparray` is a NumPy array of [PuLP](https://github.com/coin-or/pulp) variables, affine
|
|
6
|
+
expressions or constraints. Broadcasting, indexing, sums and matrix products build whole
|
|
7
|
+
families of expressions at once; `<=`, `>=` and `==` build families of constraints; and a
|
|
8
|
+
handful of methods linearise abs, max, min, argmax, top-k, logic and piecewise-linear functions.
|
|
9
|
+
Never see a `for` or an index variable again.
|
|
10
|
+
|
|
11
|
+
It needs Python 3.12+ and PuLP 4. PuLP 4 ships no solver: `pip install "pulp[cbc]"` adds CBC.
|
|
12
|
+
|
|
13
|
+
## Super Sudoku
|
|
14
|
+
|
|
15
|
+
A Super Sudoku is a Sudoku in which the cells at the same position within their boxes also hold
|
|
16
|
+
distinct digits.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import numpy as np
|
|
20
|
+
from pulp import LpBinary, LpProblem
|
|
21
|
+
|
|
22
|
+
from pulp_lparray import lparray
|
|
23
|
+
|
|
24
|
+
clues = np.array(
|
|
25
|
+
[
|
|
26
|
+
[0, 4, 0, 0, 1, 8, 0, 0, 0],
|
|
27
|
+
[0, 5, 0, 0, 0, 2, 0, 0, 0],
|
|
28
|
+
[2, 0, 8, 0, 0, 0, 7, 1, 4],
|
|
29
|
+
[1, 8, 2, 0, 0, 0, 5, 6, 0],
|
|
30
|
+
[0, 6, 0, 0, 0, 0, 0, 0, 0],
|
|
31
|
+
[4, 7, 0, 0, 0, 6, 0, 0, 2],
|
|
32
|
+
[0, 0, 5, 0, 9, 7, 0, 0, 1],
|
|
33
|
+
[0, 0, 4, 0, 0, 0, 0, 0, 0],
|
|
34
|
+
[9, 0, 0, 0, 0, 0, 3, 0, 0],
|
|
35
|
+
]
|
|
36
|
+
) # 0 marks an empty cell
|
|
37
|
+
|
|
38
|
+
prob = LpProblem("super_sudoku")
|
|
39
|
+
# board[R, C, r, c, n] == 1: the cell in row 3R + r and column 3C + c holds digit n + 1
|
|
40
|
+
board = lparray.create(prob, "board", (3, 3, 3, 3, 9), cat=LpBinary)
|
|
41
|
+
(board.sum(axis=4) == 1).constrain(prob, "cell")
|
|
42
|
+
(board.sum(axis=(1, 3)) == 1).constrain(prob, "row")
|
|
43
|
+
(board.sum(axis=(0, 2)) == 1).constrain(prob, "column")
|
|
44
|
+
(board.sum(axis=(2, 3)) == 1).constrain(prob, "box")
|
|
45
|
+
(board.sum(axis=(0, 1)) == 1).constrain(prob, "position")
|
|
46
|
+
cells = board.transpose(0, 2, 1, 3, 4).reshape(9, 9, 9) # row, column, digit
|
|
47
|
+
row, col = clues.nonzero()
|
|
48
|
+
(cells[row, col, clues[row, col] - 1] == 1).constrain(prob, "clue")
|
|
49
|
+
prob.solve()
|
|
50
|
+
|
|
51
|
+
grid = cells.values.argmax(axis=2) + 1
|
|
52
|
+
assert (grid[row, col] == clues[row, col]).all()
|
|
53
|
+
assert all(sorted(line) == list(range(1, 10)) for line in [*grid, *grid.T])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Portfolio allocation
|
|
57
|
+
|
|
58
|
+
Whole numbers of shares whose values best match target weights within a budget, measured by
|
|
59
|
+
relative error so that every position counts alike. Minimising `deviation` subject to
|
|
60
|
+
`deviation >= error` and `deviation >= -error` makes it `|error|` without binaries; `abs` is for
|
|
61
|
+
magnitudes that the objective does not push down.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pulp import LpInteger, LpMinimize
|
|
65
|
+
|
|
66
|
+
price = np.array([31.0, 77.0, 12.5, 220.0])
|
|
67
|
+
weight = np.array([0.4, 0.3, 0.2, 0.1])
|
|
68
|
+
funds = 10_000
|
|
69
|
+
|
|
70
|
+
prob = LpProblem("allocation", LpMinimize)
|
|
71
|
+
shares = lparray.create(prob, "shares", 4, lowBound=0, cat=LpInteger)
|
|
72
|
+
(shares @ price <= funds).constrain(prob, "budget")
|
|
73
|
+
target = funds * weight / price
|
|
74
|
+
error = (shares - target) / target
|
|
75
|
+
deviation = lparray.create(prob, "deviation", 4)
|
|
76
|
+
(deviation >= error).constrain(prob, "over")
|
|
77
|
+
(deviation >= -error).constrain(prob, "under")
|
|
78
|
+
prob += deviation.sum().item()
|
|
79
|
+
prob.solve()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Facility logistics
|
|
83
|
+
|
|
84
|
+
Open exactly two facilities per region, ship every product to every customer, and pay an
|
|
85
|
+
operating cost with a volume discount on each facility's throughput. The discount makes the cost
|
|
86
|
+
concave, so `lp_interp` selects its segment with binaries.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
region = np.array([0, 0, 0, 1, 1, 1])
|
|
90
|
+
capacity = np.array([12, 6, 6, 12, 6, 6])
|
|
91
|
+
open_cost = np.array([10, 5, 7, 10, 5, 7])
|
|
92
|
+
ship_cost = np.array(
|
|
93
|
+
[
|
|
94
|
+
[2, 2, 3, 9, 9],
|
|
95
|
+
[3, 1, 2, 9, 9],
|
|
96
|
+
[2, 3, 1, 9, 9],
|
|
97
|
+
[9, 9, 9, 2, 2],
|
|
98
|
+
[9, 9, 9, 3, 1],
|
|
99
|
+
[9, 9, 9, 2, 3],
|
|
100
|
+
]
|
|
101
|
+
) # facility × customer
|
|
102
|
+
demand = np.array([[4, 2, 1], [0, 3, 2], [1, 1, 0], [3, 0, 1], [2, 2, 2]]) # customer × product
|
|
103
|
+
|
|
104
|
+
prob = LpProblem("logistics", LpMinimize)
|
|
105
|
+
is_open = lparray.create(prob, "open", 6, cat=LpBinary)
|
|
106
|
+
flow = lparray.create(prob, "flow", (6, 5, 3), lowBound=0)
|
|
107
|
+
(flow.sum(axis=0) == demand).constrain(prob, "demand")
|
|
108
|
+
throughput = flow.sum(axis=(1, 2))
|
|
109
|
+
(throughput <= capacity * is_open).constrain(prob, "capacity")
|
|
110
|
+
((region == np.arange(2)[:, None]) @ is_open == 2).constrain(prob, "two_per_region")
|
|
111
|
+
# The first 5 units cost 3 each, and the rest 2 each.
|
|
112
|
+
operating = throughput.lp_interp(prob, "operating", xp=[0, 5, 12], fp=[0, 15, 29])
|
|
113
|
+
prob += ((ship_cost[:, :, None] * flow).sum() + open_cost @ is_open + operating.sum()).item()
|
|
114
|
+
prob.solve()
|
|
115
|
+
|
|
116
|
+
assert (is_open.values.reshape(2, 3).sum(axis=1) == 2).all()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Formulations
|
|
120
|
+
|
|
121
|
+
Methods taking `(prob, name)` add auxiliary variables and constraints named `{name}_{role}`.
|
|
122
|
+
|
|
123
|
+
| method | result |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `x.abs(prob, name, bigM=…)` | `\|x\|` |
|
|
126
|
+
| `x.abs_decompose(prob, name, bigM=…)` | `(p, m)`: `p - m == x`, `p, m >= 0`, one of them zero |
|
|
127
|
+
| `x.logical_clip(prob, name, bigM=…)` | binary `min(x, 1)` of integral `x >= 0` |
|
|
128
|
+
| `x.lp_max(prob, name, axis=…, bigM=…)`, `lp_min` | maximum, minimum along `axis` |
|
|
129
|
+
| `x.lp_argmax(prob, name, axis=…, bigM=…)`, `lp_argmin` | one-hot indicator of an extremum along `axis` |
|
|
130
|
+
| `x.lp_topk(prob, name, k=…, axis=…, bigM=…)` | indicator of `k` largest entries along `axis` |
|
|
131
|
+
| `b.lp_bin_max(prob, name, axis=…)`, `lp_bin_min` | logical OR, AND of binary `b` along `axis` |
|
|
132
|
+
| `z.lp_bin_and(prob, name, *bs)`, `lp_bin_or` | constrains binary `z` to the AND, OR of `bs` |
|
|
133
|
+
| `x.lp_interp(prob, name, xp=…, fp=…)` | `np.interp(x, xp, fp)`, breakpoints broadcasting against `x` |
|
|
134
|
+
|
|
135
|
+
`bigM` must bound the spread of the values involved: `|x|` for `abs`, `x` for `logical_clip`,
|
|
136
|
+
and the range along `axis` for the rest. Too small a `bigM` silently cuts off solutions.
|
|
137
|
+
`lp_interp` constrains `x` to lie between the first and last breakpoints rather than clamping it.
|
|
138
|
+
|
|
139
|
+
## Semantics
|
|
140
|
+
|
|
141
|
+
`constrain(prob, name)` adds each constraint as `name(i,j,…)` and returns the array of
|
|
142
|
+
`LpConstraint`s that the problem stores.
|
|
143
|
+
|
|
144
|
+
Sums, `@` and `dot` take time linear in the contracted length. Other contractions, such as
|
|
145
|
+
`np.einsum` and `np.inner`, run NumPy's object loops, which chain PuLP's `+` and take quadratic
|
|
146
|
+
time. Reductions return 0-d lparrays, whose `item()` is the PuLP expression. Ufuncs that are not
|
|
147
|
+
affine, such as `np.maximum` and `np.abs`, raise `TypeError`; the methods above linearise them.
|
|
148
|
+
|
|
149
|
+
Keep the lparray on the left of `+` and `-` against a single PuLP variable or expression:
|
|
150
|
+
PuLP treats an array on its right as an iterable and sums it into one expression.
|
|
151
|
+
|
|
152
|
+
## Upgrading from 1.x
|
|
153
|
+
|
|
154
|
+
| 1.x | 2.0 |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `lparray.create(name, index_sets)`, `create_anon(name, shape)`, `create_like(name, a)` | `lparray.create(prob, name, shape)` |
|
|
157
|
+
| `x.sumit()` | `x.sum().item()` |
|
|
158
|
+
| `lp_int_max`, `lp_real_max`, `lp_int_min`, `lp_real_min` | `lp_max`, `lp_min` |
|
|
159
|
+
| `lp_bin_argmax`, `lp_bin_argmin`, `lp_bin_topk` | `lp_argmax`, `lp_argmin`, `lp_topk` |
|
|
160
|
+
| `piecewise_linear_sos2` | `lp_interp` |
|
|
161
|
+
| `abs_clip` | `abs` |
|
|
162
|
+
| `one_hot`, `constrain_one_hot`, `all_different`, `indicator_le`, `indicator_ge` | the sum or comparison each wrapped |
|
|
163
|
+
| `soft_select_max`, `count_out` | removed |
|
|
164
|
+
|
|
165
|
+
Every `bigM` is required, and `constrain` returns the stored constraints.
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""NumPy arrays of PuLP variables, affine expressions and constraints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.lib.array_utils import normalize_axis_tuple
|
|
10
|
+
from pulp import (
|
|
11
|
+
LpAffineExpression,
|
|
12
|
+
LpBinary,
|
|
13
|
+
LpConstraint,
|
|
14
|
+
LpProblem,
|
|
15
|
+
LpVariable,
|
|
16
|
+
lpSum,
|
|
17
|
+
value,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
22
|
+
|
|
23
|
+
from numpy.typing import ArrayLike, NDArray
|
|
24
|
+
|
|
25
|
+
type Category = Literal["Continuous", "Integer", "Binary"]
|
|
26
|
+
type Axis = int | tuple[int, ...] | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class lparray(np.ndarray[tuple[int, ...], np.dtype[np.object_]]):
|
|
30
|
+
"""An object ndarray of PuLP variables, affine expressions or constraints.
|
|
31
|
+
|
|
32
|
+
Arithmetic, indexing and broadcasting are NumPy's, applied elementwise, and NumPy functions
|
|
33
|
+
return lparrays; ufuncs that are not affine raise TypeError. `<=`, `>=` and `==` build arrays
|
|
34
|
+
of constraints, which `constrain` adds to a problem. Sums, ``@ and `dot` take time linear in
|
|
35
|
+
the contracted length. Reductions return 0-d lparrays, whose `item()` is the PuLP scalar.
|
|
36
|
+
|
|
37
|
+
Methods taking `(prob, name)` linearise nonlinear operations with auxiliary variables and
|
|
38
|
+
constraints named `{name}_{role}`. Those taking `bigM` require it to bound the spread of the
|
|
39
|
+
values involved; too small a bigM silently cuts off feasible solutions.
|
|
40
|
+
|
|
41
|
+
Keep the lparray on the left of `+` and `-` against a scalar PuLP operand: PuLP treats a
|
|
42
|
+
right-hand array as an iterable and sums it into a single expression.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__hash__ = None
|
|
46
|
+
|
|
47
|
+
# PuLP 4 scalars set __array_priority__ = 20, so a lower priority makes ndarray operators
|
|
48
|
+
# defer to the scalar's reflected method, which sums the whole array into one expression.
|
|
49
|
+
__array_priority__ = math.inf
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
# NumPy's stubs type these as plain or boolean ndarrays; __array_ufunc__ returns lparrays.
|
|
53
|
+
def __getitem__(self, key: object, /) -> Any: ... # noqa: ANN401
|
|
54
|
+
def __neg__(self) -> lparray: ...
|
|
55
|
+
def __add__(self, other: object, /) -> lparray: ...
|
|
56
|
+
def __radd__(self, other: ArrayLike, /) -> lparray: ...
|
|
57
|
+
def __sub__(self, other: object, /) -> lparray: ...
|
|
58
|
+
def __rsub__(self, other: ArrayLike, /) -> lparray: ...
|
|
59
|
+
def __mul__(self, other: object, /) -> lparray: ...
|
|
60
|
+
def __rmul__(self, other: ArrayLike, /) -> lparray: ...
|
|
61
|
+
def __truediv__(self, other: object, /) -> lparray: ...
|
|
62
|
+
def __matmul__(self, other: object, /) -> lparray: ...
|
|
63
|
+
def __rmatmul__(self, other: ArrayLike, /) -> lparray: ...
|
|
64
|
+
# Deliberately incompatible: comparisons build constraints, and the rest keep the subclass.
|
|
65
|
+
def __le__(self, other: object, /) -> lparray: ... # ty: ignore[invalid-method-override]
|
|
66
|
+
def __ge__(self, other: object, /) -> lparray: ... # ty: ignore[invalid-method-override]
|
|
67
|
+
def __eq__(self, other: object, /) -> lparray: ... # ty: ignore[invalid-method-override]
|
|
68
|
+
def sum(self, axis: Axis = None, *, keepdims: bool = False) -> lparray: ... # ty: ignore[invalid-method-override]
|
|
69
|
+
def reshape(self, *shape: Any, **kwargs: Any) -> lparray: ... # ty: ignore[invalid-method-override]
|
|
70
|
+
def ravel(self, order: Literal["K", "A", "C", "F"] = "C") -> lparray: ... # ty: ignore[invalid-method-override]
|
|
71
|
+
def flatten(self, order: Literal["K", "A", "C", "F"] = "C") -> lparray: ... # ty: ignore[invalid-method-override]
|
|
72
|
+
def squeeze(self, axis: Axis = None) -> lparray: ... # ty: ignore[invalid-method-override]
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def create(
|
|
76
|
+
cls,
|
|
77
|
+
prob: LpProblem,
|
|
78
|
+
name: str,
|
|
79
|
+
shape: int | tuple[int, ...],
|
|
80
|
+
*,
|
|
81
|
+
lowBound: float | None = None,
|
|
82
|
+
upBound: float | None = None,
|
|
83
|
+
cat: Category = "Continuous",
|
|
84
|
+
) -> Self:
|
|
85
|
+
"""Variables of `prob` named `name(i,j,…)` by their indices."""
|
|
86
|
+
arr = np.empty(shape, dtype=object)
|
|
87
|
+
for ix in np.ndindex(arr.shape):
|
|
88
|
+
arr[ix] = prob.add_variable(_subscript(name, ix), lowBound, upBound, cat)
|
|
89
|
+
return arr.view(cls)
|
|
90
|
+
|
|
91
|
+
def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs: Any, **kwargs: Any) -> object:
|
|
92
|
+
args = [x.view(np.ndarray) if isinstance(x, lparray) else _shield(x) for x in inputs]
|
|
93
|
+
if out := kwargs.get("out"):
|
|
94
|
+
kwargs["out"] = tuple(o.view(np.ndarray) for o in out)
|
|
95
|
+
match ufunc, method:
|
|
96
|
+
case np.add, "reduce":
|
|
97
|
+
result = _lpsum(*args, **kwargs)
|
|
98
|
+
case np.matmul, "__call__":
|
|
99
|
+
result = _matmul(*args, **kwargs)
|
|
100
|
+
case np.less_equal | np.greater_equal | np.equal, "__call__":
|
|
101
|
+
# PuLP 4 compares two variables by identity, yielding a bool. A difference is
|
|
102
|
+
# always an expression, and comparing an expression with 0 is a constraint.
|
|
103
|
+
difference = _shield(np.subtract(*args, dtype=object))
|
|
104
|
+
result = ufunc(difference, 0, **kwargs | {"dtype": object})
|
|
105
|
+
case np.add | np.subtract | np.multiply | np.true_divide | np.negative | np.positive, _:
|
|
106
|
+
result = getattr(ufunc, method)(*args, **kwargs)
|
|
107
|
+
case _:
|
|
108
|
+
raise TypeError(
|
|
109
|
+
f"lparray supports affine ufuncs, <=, >= and ==, not np.{ufunc.__name__}."
|
|
110
|
+
f"{method}; lp_max, lp_min and abs linearise extrema and magnitudes"
|
|
111
|
+
)
|
|
112
|
+
if out:
|
|
113
|
+
return out[0] if len(out) == 1 else out
|
|
114
|
+
return _wrap(result)
|
|
115
|
+
|
|
116
|
+
def __array_function__(
|
|
117
|
+
self,
|
|
118
|
+
func: Callable[..., Any],
|
|
119
|
+
types: Iterable[type],
|
|
120
|
+
args: Iterable[Any],
|
|
121
|
+
kwargs: Mapping[str, Any],
|
|
122
|
+
) -> object:
|
|
123
|
+
args = tuple(map(_shield, args))
|
|
124
|
+
kwargs = {key: _shield(arg) for key, arg in kwargs.items()}
|
|
125
|
+
if func is np.dot:
|
|
126
|
+
return _wrap(_dot(*args, **kwargs))
|
|
127
|
+
return _wrap(super().__array_function__(func, types, args, kwargs))
|
|
128
|
+
|
|
129
|
+
def dot(self, b: ArrayLike, /) -> lparray: # ty: ignore[invalid-method-override]
|
|
130
|
+
"""`np.dot(self, b)`, which ndarray.dot would compute without dispatching to lparray."""
|
|
131
|
+
return np.dot(self, b)
|
|
132
|
+
|
|
133
|
+
if not TYPE_CHECKING: # NumPy's stubs type assignment as it is
|
|
134
|
+
|
|
135
|
+
def __setitem__(self, key: object, value: object, /) -> None:
|
|
136
|
+
super().__setitem__(key, _shield(value))
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def values(self) -> NDArray[np.float64]:
|
|
140
|
+
"""The solved value of each element."""
|
|
141
|
+
return np.fromiter(map(value, self.flat), np.float64, self.size).reshape(self.shape)
|
|
142
|
+
|
|
143
|
+
def constrain(self, prob: LpProblem, name: str) -> lparray:
|
|
144
|
+
"""Add each constraint to `prob` as `name(i,j,…)`; return the stored constraints."""
|
|
145
|
+
stored = np.empty(self.shape, dtype=object)
|
|
146
|
+
for ix in np.ndindex(self.shape):
|
|
147
|
+
if not isinstance(c := self[ix], LpAffineExpression):
|
|
148
|
+
raise TypeError(f"{_subscript(name, ix)}: {c!r} is not a constraint")
|
|
149
|
+
stored[ix] = prob.addConstraint(c, _subscript(name, ix))
|
|
150
|
+
return stored.view(lparray)
|
|
151
|
+
|
|
152
|
+
def abs_decompose(self, prob: LpProblem, name: str, *, bigM: float) -> tuple[lparray, lparray]:
|
|
153
|
+
"""The positive and negative parts `(p, m)` of self: `p - m == self`, `p, m >= 0`,
|
|
154
|
+
and at most one of them nonzero. Requires `|self| <= bigM`."""
|
|
155
|
+
neg = lparray.create(prob, f"{name}_neg", self.shape, cat=LpBinary)
|
|
156
|
+
p = lparray.create(prob, f"{name}_p", self.shape, lowBound=0)
|
|
157
|
+
m = lparray.create(prob, f"{name}_m", self.shape, lowBound=0)
|
|
158
|
+
(p - m == self).constrain(prob, f"{name}_split")
|
|
159
|
+
(p <= bigM * (1 - neg)).constrain(prob, f"{name}_p_off")
|
|
160
|
+
(m <= bigM * neg).constrain(prob, f"{name}_m_off")
|
|
161
|
+
return p, m
|
|
162
|
+
|
|
163
|
+
def abs(self, prob: LpProblem, name: str, *, bigM: float) -> lparray:
|
|
164
|
+
"""`|self|`; requires `|self| <= bigM`."""
|
|
165
|
+
p, m = self.abs_decompose(prob, name, bigM=bigM)
|
|
166
|
+
return p + m
|
|
167
|
+
|
|
168
|
+
def logical_clip(self, prob: LpProblem, name: str, *, bigM: float) -> lparray:
|
|
169
|
+
"""Binary `min(self, 1)` of integral `0 <= self <= bigM`."""
|
|
170
|
+
z = lparray.create(prob, f"{name}_z", self.shape, cat=LpBinary)
|
|
171
|
+
(self >= z).constrain(prob, f"{name}_lb")
|
|
172
|
+
(self <= bigM * z).constrain(prob, f"{name}_ub")
|
|
173
|
+
return z
|
|
174
|
+
|
|
175
|
+
def lp_max(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
|
|
176
|
+
"""Maximum along `axis`; requires the spread of self along it to be at most bigM."""
|
|
177
|
+
return self._extremum(prob, name, axis, bigM, 1)[0]
|
|
178
|
+
|
|
179
|
+
def lp_min(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
|
|
180
|
+
"""Minimum along `axis`; requires the spread of self along it to be at most bigM."""
|
|
181
|
+
return self._extremum(prob, name, axis, bigM, -1)[0]
|
|
182
|
+
|
|
183
|
+
def lp_argmax(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
|
|
184
|
+
"""Binary indicator, one-hot along `axis`, of an entry attaining the maximum."""
|
|
185
|
+
return self._extremum(prob, name, axis, bigM, 1)[1]
|
|
186
|
+
|
|
187
|
+
def lp_argmin(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
|
|
188
|
+
"""Binary indicator, one-hot along `axis`, of an entry attaining the minimum."""
|
|
189
|
+
return self._extremum(prob, name, axis, bigM, -1)[1]
|
|
190
|
+
|
|
191
|
+
def lp_bin_max(self, prob: LpProblem, name: str, *, axis: Axis = None) -> lparray:
|
|
192
|
+
"""Logical OR of binary self along `axis`."""
|
|
193
|
+
axes, kept = self._reduction(axis)
|
|
194
|
+
z = lparray.create(prob, f"{name}_z", kept, lowBound=0, upBound=1)
|
|
195
|
+
_all(prob, name, 1 - z, 1 - self, axes)
|
|
196
|
+
return z
|
|
197
|
+
|
|
198
|
+
def lp_bin_min(self, prob: LpProblem, name: str, *, axis: Axis = None) -> lparray:
|
|
199
|
+
"""Logical AND of binary self along `axis`."""
|
|
200
|
+
axes, kept = self._reduction(axis)
|
|
201
|
+
z = lparray.create(prob, f"{name}_z", kept, lowBound=0, upBound=1)
|
|
202
|
+
_all(prob, name, z, self, axes)
|
|
203
|
+
return z
|
|
204
|
+
|
|
205
|
+
def lp_bin_and(self, prob: LpProblem, name: str, *ins: ArrayLike) -> Self:
|
|
206
|
+
"""Constrain binary self to the elementwise logical AND of `ins`; return self."""
|
|
207
|
+
_all(prob, name, self, _stacked(ins, self.shape), (0,))
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
def lp_bin_or(self, prob: LpProblem, name: str, *ins: ArrayLike) -> Self:
|
|
211
|
+
"""Constrain binary self to the elementwise logical OR of `ins`; return self."""
|
|
212
|
+
_all(prob, name, 1 - self, 1 - _stacked(ins, self.shape), (0,))
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
def lp_topk(
|
|
216
|
+
self, prob: LpProblem, name: str, *, k: int, axis: Axis = None, bigM: float
|
|
217
|
+
) -> lparray:
|
|
218
|
+
"""Binary indicator of `k` entries along `axis`, none smaller than an unselected one."""
|
|
219
|
+
axes, kept = self._reduction(axis)
|
|
220
|
+
if not 0 <= k <= math.prod(self.shape[d] for d in axes):
|
|
221
|
+
raise ValueError(f"{name}: cannot select {k} of {self.shape} along {axes}")
|
|
222
|
+
threshold = lparray.create(prob, f"{name}_t", kept)
|
|
223
|
+
w = lparray.create(prob, f"{name}_w", self.shape, cat=LpBinary)
|
|
224
|
+
(w.sum(axis=axes) == k).constrain(prob, f"{name}_k")
|
|
225
|
+
gap = self - _expand(threshold, axes)
|
|
226
|
+
(gap >= -bigM * (1 - w)).constrain(prob, f"{name}_in")
|
|
227
|
+
(gap <= bigM * w).constrain(prob, f"{name}_out")
|
|
228
|
+
return w
|
|
229
|
+
|
|
230
|
+
def lp_interp(self, prob: LpProblem, name: str, *, xp: ArrayLike, fp: ArrayLike) -> lparray:
|
|
231
|
+
"""`np.interp(self, xp, fp)`, constraining self to `[xp[..., 0], xp[..., -1]]`.
|
|
232
|
+
|
|
233
|
+
The breakpoints `xp` and values `fp` lie along their last axis; their leading axes
|
|
234
|
+
broadcast against self."""
|
|
235
|
+
xs, fs = np.broadcast_arrays(np.asarray(xp, dtype=float), np.asarray(fp, dtype=float))
|
|
236
|
+
if xs.ndim == 0 or xs.shape[-1] < 2 or np.any(np.diff(xs) <= 0):
|
|
237
|
+
raise ValueError("xp must increase strictly along its last axis, of length >= 2")
|
|
238
|
+
shape = np.broadcast_shapes(self.shape, xs.shape[:-1])
|
|
239
|
+
weight = lparray.create(prob, f"{name}_weight", (*shape, xs.shape[-1]), lowBound=0)
|
|
240
|
+
segment = lparray.create(prob, f"{name}_segment", (*shape, xs.shape[-1] - 1), cat=LpBinary)
|
|
241
|
+
(weight.sum(axis=-1) == 1).constrain(prob, f"{name}_weight_sum")
|
|
242
|
+
(segment.sum(axis=-1) == 1).constrain(prob, f"{name}_segment_sum")
|
|
243
|
+
# A weight may be positive only beside the chosen segment: SOS2 adjacency.
|
|
244
|
+
edge = np.zeros((*shape, 1), dtype=object)
|
|
245
|
+
flanks = np.concatenate([edge, segment, edge], axis=-1)
|
|
246
|
+
(weight <= flanks[..., :-1] + flanks[..., 1:]).constrain(prob, f"{name}_adjacent")
|
|
247
|
+
(self == (weight * xs).sum(axis=-1)).constrain(prob, f"{name}_x")
|
|
248
|
+
return (weight * fs).sum(axis=-1)
|
|
249
|
+
|
|
250
|
+
def _extremum(
|
|
251
|
+
self, prob: LpProblem, name: str, axis: Axis, bigM: float, sign: Literal[1, -1]
|
|
252
|
+
) -> tuple[lparray, lparray]:
|
|
253
|
+
"""`(t, w)`: `sign·t = max(sign·self)` along `axis`, `w` one-hot where attained."""
|
|
254
|
+
axes, kept = self._reduction(axis)
|
|
255
|
+
if not math.prod(self.shape[d] for d in axes):
|
|
256
|
+
raise ValueError(f"no extremum of an empty slice of {self.shape} along {axes}")
|
|
257
|
+
t = lparray.create(prob, f"{name}_t", kept)
|
|
258
|
+
w = lparray.create(prob, f"{name}_w", self.shape, cat=LpBinary)
|
|
259
|
+
(w.sum(axis=axes) == 1).constrain(prob, f"{name}_one")
|
|
260
|
+
gap = sign * (_expand(t, axes) - self)
|
|
261
|
+
(gap >= 0).constrain(prob, f"{name}_bound")
|
|
262
|
+
(gap <= bigM * (1 - w)).constrain(prob, f"{name}_attain")
|
|
263
|
+
return t, w
|
|
264
|
+
|
|
265
|
+
def _reduction(self, axis: Axis) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
266
|
+
"""The axes reduced over and the shape left."""
|
|
267
|
+
axes = normalize_axis_tuple(range(self.ndim) if axis is None else axis, self.ndim)
|
|
268
|
+
return axes, tuple(n for d, n in enumerate(self.shape) if d not in axes)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _subscript(name: str, ix: tuple[int, ...]) -> str:
|
|
272
|
+
return f"{name}({','.join(map(str, ix))})" if ix else name
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _box(x: object) -> NDArray[np.object_]:
|
|
276
|
+
box = np.empty((), dtype=object)
|
|
277
|
+
box[()] = x
|
|
278
|
+
return box
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _shield[T](x: T) -> T | NDArray[np.object_]:
|
|
282
|
+
"""Box a PuLP 4 expression or constraint in a 0-d array, and those within lists and tuples:
|
|
283
|
+
both define __len__ and __getitem__, so NumPy would read one as the sequence of its
|
|
284
|
+
variables."""
|
|
285
|
+
match x:
|
|
286
|
+
case LpAffineExpression() | LpConstraint():
|
|
287
|
+
return _box(x)
|
|
288
|
+
case list() | tuple() if type(x) in (list, tuple):
|
|
289
|
+
return type(x)(map(_shield, x))
|
|
290
|
+
case _:
|
|
291
|
+
return x
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _wrap(x: object) -> object:
|
|
295
|
+
"""View the object ndarrays among a NumPy result as lparrays, and box the PuLP scalars that
|
|
296
|
+
NumPy returns for 0-d results into 0-d lparrays."""
|
|
297
|
+
match x:
|
|
298
|
+
case np.ndarray(dtype=np.object_):
|
|
299
|
+
return x.view(lparray)
|
|
300
|
+
case LpAffineExpression() | LpVariable() | LpConstraint():
|
|
301
|
+
return _box(x).view(lparray)
|
|
302
|
+
case tuple():
|
|
303
|
+
return tuple(map(_wrap, x))
|
|
304
|
+
case list():
|
|
305
|
+
return list(map(_wrap, x))
|
|
306
|
+
case _:
|
|
307
|
+
return x
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _lpsum(
|
|
311
|
+
a: NDArray[np.object_],
|
|
312
|
+
*,
|
|
313
|
+
axis: Axis = 0,
|
|
314
|
+
dtype: object = None,
|
|
315
|
+
keepdims: bool = False,
|
|
316
|
+
where: object = True,
|
|
317
|
+
) -> NDArray[np.object_]:
|
|
318
|
+
"""`np.add.reduce` through `lpSum`. NumPy's object reduction chains `+`, and each PuLP `+`
|
|
319
|
+
copies its left operand, so it takes time quadratic in the length reduced."""
|
|
320
|
+
if dtype not in (None, object) or where is not True:
|
|
321
|
+
raise TypeError("lparray sums take only axis and keepdims")
|
|
322
|
+
if a.ndim == 0 and axis in (0, -1):
|
|
323
|
+
axis = () # NumPy reduces a 0-d array along axis 0 to itself
|
|
324
|
+
axes = normalize_axis_tuple(range(a.ndim) if axis is None else axis, a.ndim)
|
|
325
|
+
kept = [d for d in range(a.ndim) if d not in axes]
|
|
326
|
+
rows = a.transpose(*kept, *axes).reshape(
|
|
327
|
+
*(a.shape[d] for d in kept), math.prod(a.shape[d] for d in axes)
|
|
328
|
+
)
|
|
329
|
+
out = np.empty(rows.shape[:-1], dtype=object)
|
|
330
|
+
for ix in np.ndindex(out.shape):
|
|
331
|
+
out[ix] = lpSum(rows[ix])
|
|
332
|
+
return np.expand_dims(out, axes) if keepdims else out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _matmul(a: ArrayLike, b: ArrayLike) -> NDArray[np.object_]:
|
|
336
|
+
"""`a @ b` as a sum of broadcast products, in time linear in the contracted length."""
|
|
337
|
+
lhs, rhs = np.asarray(a, dtype=object), np.asarray(b, dtype=object)
|
|
338
|
+
if not lhs.ndim or not rhs.ndim or lhs.shape[-1] != rhs.shape[-2 if rhs.ndim > 1 else 0]:
|
|
339
|
+
raise ValueError(f"matmul cannot contract {lhs.shape} with {rhs.shape}")
|
|
340
|
+
products = (lhs[None] if lhs.ndim == 1 else lhs)[..., :, :, None] * (
|
|
341
|
+
rhs[:, None] if rhs.ndim == 1 else rhs
|
|
342
|
+
)[..., None, :, :]
|
|
343
|
+
promoted = (-2,) * (lhs.ndim == 1) + (-1,) * (rhs.ndim == 1)
|
|
344
|
+
return _lpsum(products, axis=-2).squeeze(axis=promoted)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _dot(a: ArrayLike, b: ArrayLike) -> object:
|
|
348
|
+
"""`np.dot(a, b)` through `_matmul`, which agrees with it when b has at most two axes."""
|
|
349
|
+
lhs, rhs = np.asarray(a, dtype=object), np.asarray(b, dtype=object)
|
|
350
|
+
if not lhs.ndim or not rhs.ndim:
|
|
351
|
+
return np.multiply(lhs, rhs)
|
|
352
|
+
if rhs.ndim <= 2:
|
|
353
|
+
return _matmul(lhs, rhs)
|
|
354
|
+
# dot contracts the last axis of a with the second-to-last of b; b's other axes follow a's.
|
|
355
|
+
flat = np.moveaxis(rhs, -2, 0).reshape(rhs.shape[-2], -1)
|
|
356
|
+
return _matmul(lhs, flat).reshape(*lhs.shape[:-1], *rhs.shape[:-2], rhs.shape[-1])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _expand(t: lparray, axes: tuple[int, ...]) -> lparray:
|
|
360
|
+
"""A reduction's result with unit axes restored, to broadcast against its operand."""
|
|
361
|
+
return np.expand_dims(t, axes).view(lparray)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _stacked(ins: tuple[ArrayLike, ...], shape: tuple[int, ...]) -> NDArray[Any]:
|
|
365
|
+
"""`ins` broadcast to `shape` and stacked along a new first axis."""
|
|
366
|
+
return np.stack([np.broadcast_to(_shield(x), shape, subok=True) for x in ins])
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _all(prob: LpProblem, name: str, z: lparray, x: NDArray[Any], axes: tuple[int, ...]) -> None:
|
|
370
|
+
"""Constrain `0 <= z <= 1` to the logical AND of binary `x` along `axes`."""
|
|
371
|
+
n = math.prod(x.shape[d] for d in axes)
|
|
372
|
+
(_expand(z, axes) <= x).constrain(prob, f"{name}_each")
|
|
373
|
+
(z >= x.sum(axis=axes) - (n - 1)).constrain(prob, f"{name}_sum")
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pulp-lparray"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "PuLP x NumPy"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT-0"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"numpy>=2",
|
|
14
|
+
"pulp>=4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pulp[cbc]",
|
|
20
|
+
"pytest",
|
|
21
|
+
"ruff",
|
|
22
|
+
"ty",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
addopts = "--import-mode=importlib --strict-config --strict-markers"
|
|
28
|
+
filterwarnings = ["error"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build]
|
|
31
|
+
include = ["pulp_lparray"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 100
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["ALL"]
|
|
38
|
+
ignore = [
|
|
39
|
+
"D", # docstrings (intentionally not enforced)
|
|
40
|
+
"TRY", # try/except micro-style (intentionally not enforced)
|
|
41
|
+
"COM", # trailing commas (formatter handles)
|
|
42
|
+
"EM", # exception message micro-style
|
|
43
|
+
"N", # PuLP naming (lowBound, upBound, bigM) and the lowercase lparray class
|
|
44
|
+
"PLR0913",# keep high-arity internal helpers
|
|
45
|
+
"PLR2004",# magic numbers are fine
|
|
46
|
+
"TC", # type-checking blocks
|
|
47
|
+
"CPY", # no per-file copyright headers
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.format]
|
|
51
|
+
quote-style = "preserve"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint.flake8-annotations]
|
|
54
|
+
allow-star-arg-any = true # forwarding NumPy's dynamically typed dispatch arguments
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"tests/**" = ["INP001", "S101"]
|
|
58
|
+
|
|
59
|
+
[tool.ty.terminal]
|
|
60
|
+
error-on-warning = true
|