turfLP 0.2.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.
- turflp/__init__.py +8 -0
- turflp/_core.py +321 -0
- turflp/_input.py +109 -0
- turflp/_solver.py +126 -0
- turflp/datasets/__init__.py +33 -0
- turflp/datasets/cafe.csv +2501 -0
- turflp/datasets/chips.csv +601 -0
- turflp/datasets/coffee.csv +119 -0
- turflp/datasets/ham.csv +128 -0
- turflp/datasets/icecream.csv +121 -0
- turflp/datasets/lunchbags.csv +1176 -0
- turflp/datasets/pies.csv +981 -0
- turflp-0.2.0.dist-info/METADATA +97 -0
- turflp-0.2.0.dist-info/RECORD +17 -0
- turflp-0.2.0.dist-info/WHEEL +4 -0
- turflp-0.2.0.dist-info/licenses/COPYRIGHTS +36 -0
- turflp-0.2.0.dist-info/licenses/LICENSE +21 -0
turflp/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""TURF analysis (total unduplicated reach and frequency) with integer linear
|
|
2
|
+
programming. Python port of the turfLP R package; see docs/algorithm.md in
|
|
3
|
+
https://github.com/aigorahub/turfLP for the specification."""
|
|
4
|
+
|
|
5
|
+
from ._core import Portfolio, TurfWarning, turf, turf_min_cover, turf_sizes
|
|
6
|
+
|
|
7
|
+
__all__ = ["Portfolio", "TurfWarning", "turf", "turf_min_cover", "turf_sizes"]
|
|
8
|
+
__version__ = "0.2.0"
|
turflp/_core.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The turfLP algorithm. Mirrors R/turf.R; docs/algorithm.md is the spec.
|
|
2
|
+
|
|
3
|
+
Section numbers in comments refer to docs/algorithm.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from . import _solver
|
|
14
|
+
from ._input import as_reach_matrix, check_limits, check_size, check_tiebreak
|
|
15
|
+
|
|
16
|
+
EPS = 2.0 ** -52
|
|
17
|
+
MAX_SOLVES = 100
|
|
18
|
+
DEFAULT_TIEBREAK = ("frequency", "penetration")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TurfWarning(UserWarning):
|
|
22
|
+
"""A search stopped before it proved the full optimum (docs/algorithm.md section 10)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Portfolio:
|
|
27
|
+
"""A selected portfolio. `products` are 0-based column indices, ascending."""
|
|
28
|
+
products: tuple
|
|
29
|
+
names: tuple
|
|
30
|
+
size: int
|
|
31
|
+
reach: int
|
|
32
|
+
reach_prop: float
|
|
33
|
+
frequency: int
|
|
34
|
+
penetration: float
|
|
35
|
+
respondents: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Section 5: the comparison rule.
|
|
39
|
+
def tolerance(value, size):
|
|
40
|
+
return 16 * size * EPS * max(1.0, abs(value))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def signed_value(obj, selected):
|
|
44
|
+
return (1 if obj["direction"] == "min" else -1) * obj["value"](selected)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def worse_on_earlier(objectives, selected, previous, size):
|
|
48
|
+
for obj in objectives:
|
|
49
|
+
new = signed_value(obj, selected)
|
|
50
|
+
old = signed_value(obj, previous)
|
|
51
|
+
if new > old + tolerance(old, size):
|
|
52
|
+
return True
|
|
53
|
+
if new < old - tolerance(old, size):
|
|
54
|
+
return False
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def best_of(pool, objectives, size):
|
|
59
|
+
keep = list(range(len(pool)))
|
|
60
|
+
for obj in objectives:
|
|
61
|
+
v = [signed_value(obj, pool[i]) for i in keep]
|
|
62
|
+
lo = min(v)
|
|
63
|
+
keep = [i for i, x in zip(keep, v) if x <= lo + tolerance(lo, size)]
|
|
64
|
+
return pool[keep[0]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _warn(text):
|
|
68
|
+
warnings.warn(text, TurfWarning, stacklevel=3)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_seconds(x):
|
|
72
|
+
# R formats the budget with %g.
|
|
73
|
+
return "%g" % x
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _portfolio(reach, names, products):
|
|
77
|
+
products = sorted(int(p) for p in products)
|
|
78
|
+
r = reach[:, products].sum(axis=0) if products else np.zeros(0)
|
|
79
|
+
reached = int(np.any(reach[:, products] > 0, axis=1).sum()) if products else 0
|
|
80
|
+
k = len(products)
|
|
81
|
+
return Portfolio(
|
|
82
|
+
products=tuple(products),
|
|
83
|
+
names=tuple(names[p] for p in products),
|
|
84
|
+
size=k,
|
|
85
|
+
reach=reached,
|
|
86
|
+
reach_prop=reached / reach.shape[0],
|
|
87
|
+
frequency=int(r.sum()),
|
|
88
|
+
penetration=k / math.fsum(1.0 / x for x in r),
|
|
89
|
+
respondents=int(reach.shape[0]),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def turf(reach, size, tiebreak=DEFAULT_TIEBREAK, *, names=None, max_pool=1000,
|
|
94
|
+
max_pool_seconds=30.0):
|
|
95
|
+
"""Find the portfolio of `size` products with maximum reach.
|
|
96
|
+
|
|
97
|
+
Ties on reach are broken by the criteria in `tiebreak`, in order
|
|
98
|
+
("frequency", "penetration", or an empty sequence for reach only).
|
|
99
|
+
Returns a Portfolio. Emits TurfWarning when a search stops early.
|
|
100
|
+
"""
|
|
101
|
+
reach, names = as_reach_matrix(reach, names)
|
|
102
|
+
tiebreak = check_tiebreak(tiebreak)
|
|
103
|
+
check_limits(max_pool, max_pool_seconds)
|
|
104
|
+
return _turf(reach, names, size, tiebreak, max_pool, max_pool_seconds)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _turf(reach, names, size, tiebreak, max_pool, max_pool_seconds):
|
|
108
|
+
# Section 2: reduction.
|
|
109
|
+
prod_reach = reach.sum(axis=0)
|
|
110
|
+
cand = np.flatnonzero(prod_reach > 0)
|
|
111
|
+
size = check_size(size, len(cand))
|
|
112
|
+
a = reach[np.ix_(reach[:, cand].sum(axis=1) > 0, cand)]
|
|
113
|
+
n, m = a.shape
|
|
114
|
+
x_vars = np.arange(n, n + m)
|
|
115
|
+
n_vars = n + m + 1
|
|
116
|
+
cand_reach = prod_reach[cand]
|
|
117
|
+
pen_coef = cand_reach.max() / cand_reach
|
|
118
|
+
|
|
119
|
+
# Section 3: the model.
|
|
120
|
+
rows_i, cols_j = np.nonzero(a)
|
|
121
|
+
counts = np.bincount(rows_i, minlength=n)
|
|
122
|
+
start = np.zeros(n + 3, dtype=np.int32)
|
|
123
|
+
start[1:n + 1] = np.cumsum(counts + 1)
|
|
124
|
+
index = np.empty(int(start[n]), dtype=np.int32)
|
|
125
|
+
value = np.ones(int(start[n]))
|
|
126
|
+
# Respondent row i: z_i + sum_j a_ij x_j >= 1 (z_i first, then x_j in column order).
|
|
127
|
+
pos = start[:n].copy()
|
|
128
|
+
index[pos] = np.arange(n)
|
|
129
|
+
pos += 1
|
|
130
|
+
order = np.lexsort((cols_j, rows_i))
|
|
131
|
+
fill = np.repeat(pos, counts) + (np.arange(len(order)) - np.repeat(np.cumsum(counts) - counts, counts))
|
|
132
|
+
index[fill] = n + cols_j[order]
|
|
133
|
+
index = np.concatenate([index, x_vars, x_vars, [n_vars - 1]]).astype(np.int32)
|
|
134
|
+
value = np.concatenate([value, np.ones(m), -pen_coef, [1.0]])
|
|
135
|
+
start[n + 1] = start[n] + m
|
|
136
|
+
start[n + 2] = start[n + 1] + m + 1
|
|
137
|
+
lower = np.concatenate([np.ones(n), [size, 0.0]])
|
|
138
|
+
upper = np.concatenate([np.full(n, np.inf), [size, 0.0]])
|
|
139
|
+
model = _solver.Model(n_vars, (start, index, value, lower, upper))
|
|
140
|
+
|
|
141
|
+
def on_x(coef):
|
|
142
|
+
return x_vars, np.asarray(coef, dtype=np.float64)
|
|
143
|
+
|
|
144
|
+
# Symmetry rows for identical products.
|
|
145
|
+
keys = [tuple(np.flatnonzero(a[:, j])) for j in range(m)]
|
|
146
|
+
last_seen = {}
|
|
147
|
+
for j, key in enumerate(keys):
|
|
148
|
+
if key in last_seen:
|
|
149
|
+
model = model.add_row([n + last_seen[key], n + j], [1.0, -1.0], 0.0, math.inf)
|
|
150
|
+
last_seen[key] = j
|
|
151
|
+
|
|
152
|
+
# Section 4: criteria. `sel` is a boolean vector over the candidates.
|
|
153
|
+
def reach_value(sel):
|
|
154
|
+
return n - int(np.any(a[:, sel] > 0, axis=1).sum())
|
|
155
|
+
|
|
156
|
+
z_coef = np.concatenate([np.ones(n), np.zeros(m + 1)])
|
|
157
|
+
objectives = {
|
|
158
|
+
"reach": {"name": "reach", "direction": "min", "coef": z_coef,
|
|
159
|
+
"row": (np.arange(n), np.ones(n)), "value": reach_value},
|
|
160
|
+
"frequency": {"name": "frequency", "direction": "max",
|
|
161
|
+
"coef": np.concatenate([np.zeros(n), cand_reach, [0.0]]),
|
|
162
|
+
"row": on_x(cand_reach),
|
|
163
|
+
"value": lambda sel: int(cand_reach[sel].sum())},
|
|
164
|
+
"penetration": {"name": "penetration", "direction": "min",
|
|
165
|
+
"coef": np.concatenate([np.zeros(n + m), [1.0]]),
|
|
166
|
+
"row": (np.array([n_vars - 1]), np.array([1.0])),
|
|
167
|
+
"value": lambda sel: math.fsum(pen_coef[sel])},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def add_bound(model, obj, rhs):
|
|
171
|
+
idx, val = obj["row"]
|
|
172
|
+
if obj["direction"] == "min":
|
|
173
|
+
return model.add_row(idx, val, -math.inf, rhs)
|
|
174
|
+
return model.add_row(idx, val, rhs, math.inf)
|
|
175
|
+
|
|
176
|
+
def exclude(model, sel):
|
|
177
|
+
return model.add_row(x_vars[sel], np.ones(int(sel.sum())), -math.inf, size - 1)
|
|
178
|
+
|
|
179
|
+
# Section 6: one stage with checks.
|
|
180
|
+
def next_valid(model, obj, earlier, previous, deadline=math.inf):
|
|
181
|
+
for _ in range(MAX_SOLVES):
|
|
182
|
+
remaining = deadline - time.monotonic()
|
|
183
|
+
if remaining <= 0:
|
|
184
|
+
return "time", None, model
|
|
185
|
+
sol = _solver.solve(obj["direction"], obj["coef"], model, x_vars, obj["name"],
|
|
186
|
+
soft=previous is not None, time_limit=remaining)
|
|
187
|
+
if sol is None:
|
|
188
|
+
return "none", None, model
|
|
189
|
+
if isinstance(sol, str):
|
|
190
|
+
if sol == "time":
|
|
191
|
+
return "time", None, model
|
|
192
|
+
if previous is None:
|
|
193
|
+
raise RuntimeError("HiGHS returned an invalid solution in the %s stage."
|
|
194
|
+
% obj["name"])
|
|
195
|
+
return "invalid", None, model
|
|
196
|
+
x = sol[x_vars]
|
|
197
|
+
sel = x > 0.5
|
|
198
|
+
if (np.isnan(x).any() or int(sel.sum()) != size
|
|
199
|
+
or np.any(np.abs(x - sel) > 1e-6)):
|
|
200
|
+
if previous is None:
|
|
201
|
+
raise RuntimeError("HiGHS returned an invalid solution in the %s stage."
|
|
202
|
+
% obj["name"])
|
|
203
|
+
return "invalid", None, model
|
|
204
|
+
if previous is None or not worse_on_earlier(earlier, sel, previous, size):
|
|
205
|
+
return "ok", sel, model
|
|
206
|
+
model = exclude(model, sel)
|
|
207
|
+
return "limit", None, model
|
|
208
|
+
|
|
209
|
+
def reason(status):
|
|
210
|
+
return {"limit": "%d solves" % MAX_SOLVES,
|
|
211
|
+
"time": "time limit of %s seconds" % _format_seconds(max_pool_seconds),
|
|
212
|
+
"invalid": "HiGHS returned an invalid solution",
|
|
213
|
+
"none": "HiGHS found no valid portfolio",
|
|
214
|
+
"full": "more than %d portfolios are within the solver tolerance" % max_pool,
|
|
215
|
+
}[status]
|
|
216
|
+
|
|
217
|
+
def unfinished(stage, status, earlier_exact=True):
|
|
218
|
+
middle = ("the earlier criteria but" if earlier_exact else
|
|
219
|
+
"penetration only to within the solver tolerance and")
|
|
220
|
+
_warn("The %s stage did not finish (%s). The portfolio is optimal on %s may not "
|
|
221
|
+
"be optimal on %s." % (stage, reason(status), middle, stage))
|
|
222
|
+
|
|
223
|
+
# Section 7: the stage loop.
|
|
224
|
+
stages = ["reach"] + list(tiebreak)
|
|
225
|
+
previous = None
|
|
226
|
+
selected = None
|
|
227
|
+
for s, stage in enumerate(stages):
|
|
228
|
+
obj = objectives[stage]
|
|
229
|
+
earlier = [objectives[t] for t in stages[:s]]
|
|
230
|
+
status, sel, new_model = next_valid(model, obj, earlier, previous)
|
|
231
|
+
if status != "ok":
|
|
232
|
+
unfinished(stage, status)
|
|
233
|
+
break
|
|
234
|
+
model = new_model
|
|
235
|
+
selected = sel
|
|
236
|
+
|
|
237
|
+
if stage == "penetration":
|
|
238
|
+
# Section 8: the penetration stage.
|
|
239
|
+
without_pool = model
|
|
240
|
+
bound = obj["value"](selected)
|
|
241
|
+
pool = [selected]
|
|
242
|
+
last = selected
|
|
243
|
+
model = add_bound(model, obj, bound)
|
|
244
|
+
pool_status = "full"
|
|
245
|
+
deadline = time.monotonic() + max_pool_seconds
|
|
246
|
+
for _ in range(max_pool):
|
|
247
|
+
model = exclude(model, last)
|
|
248
|
+
status, sel, model = next_valid(model, obj, earlier, previous, deadline)
|
|
249
|
+
if status != "ok":
|
|
250
|
+
pool_status = "complete" if status == "none" else status
|
|
251
|
+
break
|
|
252
|
+
last = sel
|
|
253
|
+
# Skip products that meet the bound only within the solver tolerance.
|
|
254
|
+
if obj["value"](last) <= bound + tolerance(bound, size):
|
|
255
|
+
pool.append(last)
|
|
256
|
+
|
|
257
|
+
if pool_status == "complete":
|
|
258
|
+
selected = best_of(pool, [objectives[t] for t in stages[s:]], size)
|
|
259
|
+
else:
|
|
260
|
+
_warn("The search for the penetration optimum stopped (%s). The penetration "
|
|
261
|
+
"is optimal only to within the solver tolerance (about 1e-9)."
|
|
262
|
+
% reason(pool_status))
|
|
263
|
+
selected = best_of(pool, [obj], size)
|
|
264
|
+
if s + 1 < len(stages):
|
|
265
|
+
later = objectives[stages[s + 1]]
|
|
266
|
+
status, sel, _ = next_valid(
|
|
267
|
+
add_bound(without_pool, obj, obj["value"](selected)),
|
|
268
|
+
later, [objectives[t] for t in stages[:s + 1]], selected,
|
|
269
|
+
time.monotonic() + max_pool_seconds)
|
|
270
|
+
if status == "ok":
|
|
271
|
+
selected = sel
|
|
272
|
+
else:
|
|
273
|
+
unfinished(stages[s + 1], status, earlier_exact=False)
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
previous = selected
|
|
277
|
+
if s + 1 < len(stages):
|
|
278
|
+
# Fix this criterion at the value the selected products give.
|
|
279
|
+
model = add_bound(model, obj, obj["value"](selected))
|
|
280
|
+
|
|
281
|
+
return _portfolio(reach, names, cand[selected])
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def turf_min_cover(reach, *, names=None):
|
|
285
|
+
"""Find the smallest portfolio that reaches every reachable respondent."""
|
|
286
|
+
reach, names = as_reach_matrix(reach, names)
|
|
287
|
+
return _min_cover(reach, names)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _min_cover(reach, names):
|
|
291
|
+
cand = np.flatnonzero(reach.sum(axis=0) > 0)
|
|
292
|
+
if len(cand) == 0:
|
|
293
|
+
raise ValueError("No product reaches any respondent.")
|
|
294
|
+
a = reach[np.ix_(reach[:, cand].sum(axis=1) > 0, cand)]
|
|
295
|
+
n, m = a.shape
|
|
296
|
+
rows_i, cols_j = np.nonzero(a)
|
|
297
|
+
order = np.lexsort((cols_j, rows_i))
|
|
298
|
+
counts = np.bincount(rows_i, minlength=n)
|
|
299
|
+
start = np.concatenate([[0], np.cumsum(counts)]).astype(np.int32)
|
|
300
|
+
model = _solver.Model(m, (start, cols_j[order].astype(np.int32), np.ones(len(order)),
|
|
301
|
+
np.ones(n), np.full(n, np.inf)))
|
|
302
|
+
sol = _solver.solve("min", np.ones(m), model, np.arange(m), "set cover")
|
|
303
|
+
sel = sol > 0.5
|
|
304
|
+
# Section 11: check the cover.
|
|
305
|
+
if not np.all(np.any(a[:, sel] > 0, axis=1)) or np.any(np.abs(sol - sel) > 1e-6):
|
|
306
|
+
raise RuntimeError("HiGHS returned an invalid solution in the set cover stage.")
|
|
307
|
+
return _portfolio(reach, names, cand[sel])
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def turf_sizes(reach, sizes=None, tiebreak=DEFAULT_TIEBREAK, *, names=None, max_pool=1000,
|
|
311
|
+
max_pool_seconds=30.0):
|
|
312
|
+
"""Solve `turf` for each size and return a list of Portfolio values.
|
|
313
|
+
|
|
314
|
+
The default sizes are 1 to the size of the minimum cover.
|
|
315
|
+
"""
|
|
316
|
+
reach, names = as_reach_matrix(reach, names)
|
|
317
|
+
tiebreak = check_tiebreak(tiebreak)
|
|
318
|
+
check_limits(max_pool, max_pool_seconds)
|
|
319
|
+
if sizes is None:
|
|
320
|
+
sizes = range(1, _min_cover(reach, names).size + 1)
|
|
321
|
+
return [_turf(reach, names, k, tiebreak, max_pool, max_pool_seconds) for k in sizes]
|
turflp/_input.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Input checks. Mirrors as_reach_matrix(), check_size(), and check_tiebreak()
|
|
2
|
+
in R/turf.R; the error texts are those of docs/algorithm.md section 1."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import numbers
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
CRITERIA = ("frequency", "penetration")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def as_reach_matrix(reach, names=None):
|
|
13
|
+
"""Return (float 0/1 matrix, list of product names)."""
|
|
14
|
+
columns = None
|
|
15
|
+
if hasattr(reach, "to_numpy") and hasattr(reach, "columns"):
|
|
16
|
+
columns = [str(c) for c in reach.columns]
|
|
17
|
+
reach = reach.to_numpy()
|
|
18
|
+
if isinstance(reach, np.ndarray):
|
|
19
|
+
arr = reach
|
|
20
|
+
elif isinstance(reach, (list, tuple)):
|
|
21
|
+
if not all(isinstance(row, (list, tuple, np.ndarray)) for row in reach):
|
|
22
|
+
raise TypeError("`reach` must be a numeric or logical matrix or data frame.")
|
|
23
|
+
widths = {len(row) for row in reach}
|
|
24
|
+
if len(widths) > 1:
|
|
25
|
+
raise ValueError("`reach` must be a numeric or logical matrix or data frame.")
|
|
26
|
+
arr = np.empty((len(reach), widths.pop() if widths else 0), dtype=object)
|
|
27
|
+
for i, row in enumerate(reach):
|
|
28
|
+
arr[i, :] = list(row)
|
|
29
|
+
else:
|
|
30
|
+
raise TypeError("`reach` must be a numeric or logical matrix or data frame.")
|
|
31
|
+
|
|
32
|
+
if arr.ndim != 2:
|
|
33
|
+
raise TypeError("`reach` must be a numeric or logical matrix or data frame.")
|
|
34
|
+
if arr.dtype.kind in "biuf":
|
|
35
|
+
pass
|
|
36
|
+
elif arr.dtype.kind == "O":
|
|
37
|
+
for v in arr.flat:
|
|
38
|
+
if v is None:
|
|
39
|
+
continue
|
|
40
|
+
if isinstance(v, (bool, np.bool_)) or (
|
|
41
|
+
isinstance(v, numbers.Real) and not isinstance(v, str)):
|
|
42
|
+
continue
|
|
43
|
+
raise TypeError("`reach` must be a numeric or logical matrix or data frame.")
|
|
44
|
+
else:
|
|
45
|
+
raise TypeError("`reach` must be a numeric or logical matrix or data frame.")
|
|
46
|
+
if arr.shape[0] == 0 or arr.shape[1] == 0:
|
|
47
|
+
raise ValueError("`reach` must have at least one row and one column.")
|
|
48
|
+
if arr.dtype.kind == "O":
|
|
49
|
+
if any(v is None or (isinstance(v, float) and math.isnan(v)) for v in arr.flat):
|
|
50
|
+
raise ValueError("`reach` must not contain missing values.")
|
|
51
|
+
arr = arr.astype(np.float64)
|
|
52
|
+
elif arr.dtype.kind == "f" and np.isnan(arr).any():
|
|
53
|
+
raise ValueError("`reach` must not contain missing values.")
|
|
54
|
+
arr = arr.astype(np.float64)
|
|
55
|
+
if not np.all((arr == 0) | (arr == 1)):
|
|
56
|
+
raise ValueError("`reach` must contain only 0 and 1, or FALSE and TRUE.")
|
|
57
|
+
|
|
58
|
+
if names is not None:
|
|
59
|
+
names = [str(n) for n in names]
|
|
60
|
+
if len(names) != arr.shape[1]:
|
|
61
|
+
raise ValueError("`names` must have one name per column of `reach`.")
|
|
62
|
+
elif columns is not None:
|
|
63
|
+
names = columns
|
|
64
|
+
else:
|
|
65
|
+
names = ["P%d" % (j + 1) for j in range(arr.shape[1])]
|
|
66
|
+
return arr, names
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_size(size, max_size):
|
|
70
|
+
ok = (isinstance(size, numbers.Real) and not isinstance(size, (bool, np.bool_))
|
|
71
|
+
and math.isfinite(size) and size >= 1 and size == int(size))
|
|
72
|
+
if not ok:
|
|
73
|
+
raise ValueError("`size` must be a single whole number of 1 or more.")
|
|
74
|
+
size = int(size)
|
|
75
|
+
if size > max_size:
|
|
76
|
+
raise ValueError("`size` is %d, but only %d products reach at least one respondent."
|
|
77
|
+
% (size, max_size))
|
|
78
|
+
return size
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_tiebreak(tiebreak):
|
|
82
|
+
if tiebreak is None:
|
|
83
|
+
return []
|
|
84
|
+
if isinstance(tiebreak, str):
|
|
85
|
+
tiebreak = [tiebreak]
|
|
86
|
+
try:
|
|
87
|
+
tiebreak = list(tiebreak)
|
|
88
|
+
except TypeError:
|
|
89
|
+
raise TypeError('`tiebreak` must contain only "frequency" and "penetration".') from None
|
|
90
|
+
out = []
|
|
91
|
+
for t in tiebreak:
|
|
92
|
+
if t not in CRITERIA:
|
|
93
|
+
raise ValueError('`tiebreak` must contain only "frequency" and "penetration".')
|
|
94
|
+
if t not in out:
|
|
95
|
+
out.append(t)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_limits(max_pool, max_pool_seconds):
|
|
100
|
+
"""Check the limits of docs/algorithm.md section 14 before any solve."""
|
|
101
|
+
if (not isinstance(max_pool, numbers.Integral) or isinstance(max_pool, (bool, np.bool_))):
|
|
102
|
+
raise TypeError("`max_pool` must be a whole number of 0 or more.")
|
|
103
|
+
if max_pool < 0:
|
|
104
|
+
raise ValueError("`max_pool` must be a whole number of 0 or more.")
|
|
105
|
+
if (not isinstance(max_pool_seconds, numbers.Real)
|
|
106
|
+
or isinstance(max_pool_seconds, (bool, np.bool_))):
|
|
107
|
+
raise TypeError("`max_pool_seconds` must be a number of 0 or more (math.inf for no limit).")
|
|
108
|
+
if math.isnan(max_pool_seconds) or max_pool_seconds < 0:
|
|
109
|
+
raise ValueError("`max_pool_seconds` must be a number of 0 or more (math.inf for no limit).")
|
turflp/_solver.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""HiGHS through highspy. Mirrors solve_lp() in R/turf.R with the settings of
|
|
2
|
+
docs/algorithm.md section 13."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import highspy
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Settings:
|
|
13
|
+
# Presolve is the one named difference from R (docs/algorithm.md section 13).
|
|
14
|
+
# Tests switch it off to run the conformance suite a second time.
|
|
15
|
+
presolve: str = "on"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
settings = Settings()
|
|
19
|
+
# The options of the most recent solve, read back from HiGHS. Tests check them.
|
|
20
|
+
last_options = {}
|
|
21
|
+
|
|
22
|
+
OPTION_NAMES = ("presolve", "mip_rel_gap", "mip_abs_gap", "primal_feasibility_tolerance",
|
|
23
|
+
"mip_feasibility_tolerance", "threads", "time_limit")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Model:
|
|
27
|
+
"""Rows of a linear model, stored so that adding a row copies nothing large.
|
|
28
|
+
|
|
29
|
+
`base` holds the rows built once per call (CSR arrays); `extra` holds the
|
|
30
|
+
rows added later (bounds and no-good cuts), as in the R model list.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, n_vars, base, extra=()):
|
|
34
|
+
self.n_vars = n_vars
|
|
35
|
+
self.base = base # (start, index, value, lower, upper)
|
|
36
|
+
self.extra = tuple(extra) # ((index, value, lower, upper), ...)
|
|
37
|
+
|
|
38
|
+
def add_row(self, index, value, lower, upper):
|
|
39
|
+
row = (np.asarray(index, dtype=np.int32), np.asarray(value, dtype=np.float64),
|
|
40
|
+
float(lower), float(upper))
|
|
41
|
+
return Model(self.n_vars, self.base, self.extra + (row,))
|
|
42
|
+
|
|
43
|
+
def csr(self):
|
|
44
|
+
start, index, value, lower, upper = self.base
|
|
45
|
+
if not self.extra:
|
|
46
|
+
return start, index, value, lower, upper
|
|
47
|
+
starts = [start[:-1]]
|
|
48
|
+
idx = [index]
|
|
49
|
+
val = [value]
|
|
50
|
+
pos = int(start[-1])
|
|
51
|
+
extra_starts = []
|
|
52
|
+
for i, v, _, _ in self.extra:
|
|
53
|
+
extra_starts.append(pos)
|
|
54
|
+
idx.append(i)
|
|
55
|
+
val.append(v)
|
|
56
|
+
pos += len(i)
|
|
57
|
+
starts.append(np.asarray(extra_starts + [pos], dtype=np.int32))
|
|
58
|
+
return (np.concatenate(starts), np.concatenate(idx), np.concatenate(val),
|
|
59
|
+
np.concatenate([lower, [r[2] for r in self.extra]]),
|
|
60
|
+
np.concatenate([upper, [r[3] for r in self.extra]]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def solve(direction, objective, model, binary, stage, soft=False, time_limit=math.inf):
|
|
64
|
+
"""Solve one stage.
|
|
65
|
+
|
|
66
|
+
Returns the column values. With `soft`, an infeasible model gives None,
|
|
67
|
+
the time limit gives "time", and any other failure gives "invalid";
|
|
68
|
+
without `soft`, all of these raise RuntimeError.
|
|
69
|
+
"""
|
|
70
|
+
n = model.n_vars
|
|
71
|
+
start, index, value, lower, upper = model.csr()
|
|
72
|
+
lp = highspy.HighsLp()
|
|
73
|
+
lp.num_col_ = n
|
|
74
|
+
lp.num_row_ = len(lower)
|
|
75
|
+
lp.col_cost_ = np.asarray(objective, dtype=np.float64)
|
|
76
|
+
col_lower = np.zeros(n)
|
|
77
|
+
col_upper = np.full(n, highspy.kHighsInf)
|
|
78
|
+
col_upper[binary] = 1.0
|
|
79
|
+
lp.col_lower_ = col_lower
|
|
80
|
+
lp.col_upper_ = col_upper
|
|
81
|
+
integrality = [highspy.HighsVarType.kContinuous] * n
|
|
82
|
+
for j in binary:
|
|
83
|
+
integrality[j] = highspy.HighsVarType.kInteger
|
|
84
|
+
lp.integrality_ = integrality
|
|
85
|
+
lp.row_lower_ = np.where(np.isinf(lower), -highspy.kHighsInf, lower)
|
|
86
|
+
lp.row_upper_ = np.where(np.isinf(upper), highspy.kHighsInf, upper)
|
|
87
|
+
lp.a_matrix_.format_ = highspy.MatrixFormat.kRowwise
|
|
88
|
+
lp.a_matrix_.num_col_ = n
|
|
89
|
+
lp.a_matrix_.num_row_ = len(lower)
|
|
90
|
+
lp.a_matrix_.start_ = start
|
|
91
|
+
lp.a_matrix_.index_ = index
|
|
92
|
+
lp.a_matrix_.value_ = value
|
|
93
|
+
lp.sense_ = highspy.ObjSense.kMaximize if direction == "max" else highspy.ObjSense.kMinimize
|
|
94
|
+
|
|
95
|
+
h = highspy.Highs()
|
|
96
|
+
h.setOptionValue("output_flag", False)
|
|
97
|
+
h.setOptionValue("presolve", settings.presolve)
|
|
98
|
+
h.setOptionValue("mip_rel_gap", 0.0)
|
|
99
|
+
h.setOptionValue("mip_abs_gap", 0.0)
|
|
100
|
+
h.setOptionValue("primal_feasibility_tolerance", 1e-9)
|
|
101
|
+
h.setOptionValue("mip_feasibility_tolerance", 1e-9)
|
|
102
|
+
h.setOptionValue("threads", 1)
|
|
103
|
+
if math.isfinite(time_limit):
|
|
104
|
+
h.setOptionValue("time_limit", float(time_limit))
|
|
105
|
+
h.passModel(lp)
|
|
106
|
+
h.run()
|
|
107
|
+
last_options.clear()
|
|
108
|
+
for name in OPTION_NAMES:
|
|
109
|
+
value = h.getOptionValue(name)
|
|
110
|
+
# highspy 1.15 returns (status, value).
|
|
111
|
+
last_options[name] = value[1] if isinstance(value, tuple) else value
|
|
112
|
+
|
|
113
|
+
status = h.getModelStatus()
|
|
114
|
+
if status == highspy.HighsModelStatus.kOptimal:
|
|
115
|
+
return np.asarray(h.getSolution().col_value, dtype=np.float64)
|
|
116
|
+
# No model here is unbounded, so HiGHS's "unbounded or infeasible" means infeasible.
|
|
117
|
+
infeasible = status in (highspy.HighsModelStatus.kInfeasible,
|
|
118
|
+
highspy.HighsModelStatus.kUnboundedOrInfeasible)
|
|
119
|
+
if soft:
|
|
120
|
+
if infeasible:
|
|
121
|
+
return None
|
|
122
|
+
if status == highspy.HighsModelStatus.kTimeLimit:
|
|
123
|
+
return "time"
|
|
124
|
+
return "invalid"
|
|
125
|
+
raise RuntimeError("HiGHS did not find a solution in the %s stage (%s)."
|
|
126
|
+
% (stage, h.modelStatusToString(status)))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Example data sets, copied from the turfLP R package.
|
|
2
|
+
|
|
3
|
+
The values are the raw responses. The R help pages suggest these reach
|
|
4
|
+
thresholds: icecream >= 8, chips >= 4, ham >= 7, coffee >= 8; cafe, pies,
|
|
5
|
+
and lunchbags are already 0/1. See COPYRIGHTS for sources and licenses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
_NAMES = ("cafe", "chips", "coffee", "ham", "icecream", "lunchbags", "pies")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Dataset(NamedTuple):
|
|
18
|
+
columns: tuple
|
|
19
|
+
values: np.ndarray
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def names():
|
|
23
|
+
"""The names of the seven data sets."""
|
|
24
|
+
return _NAMES
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load(name):
|
|
28
|
+
"""Load a data set as Dataset(columns, values)."""
|
|
29
|
+
if name not in _NAMES:
|
|
30
|
+
raise ValueError("Unknown data set %r. Use one of: %s." % (name, ", ".join(_NAMES)))
|
|
31
|
+
text = resources.files(__name__).joinpath(name + ".csv").read_text(encoding="utf-8")
|
|
32
|
+
rows = list(csv.reader(text.splitlines()))
|
|
33
|
+
return Dataset(tuple(rows[0]), np.array(rows[1:], dtype=np.int64))
|