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 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))