turfLP 0.2.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.
@@ -0,0 +1,24 @@
1
+ .Rproj.user
2
+ .Rhistory
3
+ .RData
4
+ .Ruserdata
5
+ .DS_Store
6
+ *.tar.gz
7
+ *.Rcheck/
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ .pytest_cache/
13
+ .venv/
14
+ *.egg-info/
15
+ python/dist/
16
+ python/build/
17
+
18
+ # JavaScript
19
+ node_modules/
20
+ js/dist/
21
+ *.tgz
22
+ .next/
23
+ js/examples/nextjs/out/
24
+ dashboard/dist/
@@ -0,0 +1,36 @@
1
+ The turflp Python package is Copyright (c) 2021-2026 Aigora and licensed
2
+ under the MIT License. See the LICENSE file.
3
+
4
+ The example data sets in turflp/datasets are copies of the data sets in the
5
+ turfLP R package (https://github.com/aigorahub/turfLP). The data sets
6
+ icecream, chips, and cafe are simulated and licensed under the MIT License.
7
+
8
+ The other data sets come from public data under their own licenses. Each
9
+ was changed as described in the R package help pages and in data-raw/real.R
10
+ in that repository.
11
+
12
+ ham
13
+ Berget, I. (2024). Dataset on rapid sensory methods for cured hams and
14
+ associations with consumer values in the Schwartz model [Data set].
15
+ Zenodo. https://doi.org/10.5281/zenodo.10996096
16
+ License: Creative Commons Attribution 4.0 International (CC BY 4.0),
17
+ https://creativecommons.org/licenses/by/4.0/
18
+
19
+ coffee
20
+ Ristenpart, W., Cotter, A. R., & Guinard, J.-X. (2023). Consumer
21
+ preference data for black coffee [Data set]. Dryad.
22
+ https://doi.org/10.25338/B8993H
23
+ License: CC0 1.0 Universal Public Domain Dedication,
24
+ https://creativecommons.org/publicdomain/zero/1.0/
25
+
26
+ pies
27
+ FiveThirtyEight (2015). thanksgiving-2015 [Data set].
28
+ https://github.com/fivethirtyeight/data/tree/master/thanksgiving-2015
29
+ License: Creative Commons Attribution 4.0 International (CC BY 4.0),
30
+ https://creativecommons.org/licenses/by/4.0/
31
+
32
+ lunchbags
33
+ Chen, D. (2015). Online Retail [Data set]. UCI Machine Learning
34
+ Repository. https://doi.org/10.24432/C5BW33
35
+ License: Creative Commons Attribution 4.0 International (CC BY 4.0),
36
+ https://creativecommons.org/licenses/by/4.0/
turflp-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2026 Aigora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
turflp-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: turfLP
3
+ Version: 0.2.0
4
+ Summary: TURF analysis (total unduplicated reach and frequency) with integer linear programming
5
+ Project-URL: Homepage, https://github.com/aigorahub/turfLP
6
+ Project-URL: Source, https://github.com/aigorahub/turfLP/tree/main/python
7
+ Author-email: John Ennis <john.m.ennis@aigora.com>
8
+ License-Expression: MIT
9
+ License-File: COPYRIGHTS
10
+ License-File: LICENSE
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: highspy<1.16,>=1.15.1
15
+ Requires-Dist: numpy>=1.23
16
+ Provides-Extra: test
17
+ Requires-Dist: pandas>=2; extra == 'test'
18
+ Requires-Dist: pytest>=8; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # turflp for Python
22
+
23
+ TURF analysis (total unduplicated reach and frequency) with integer linear programming. This is the Python port of the [turfLP R package](https://github.com/aigorahub/turfLP). It uses the same algorithm, solver settings, limits, and warning texts, and it passes the same conformance suite (`conformance/` in the repository), whose expected values come from exact enumeration of every portfolio.
24
+
25
+ The solver is [HiGHS](https://highs.dev) through `highspy`. The dependencies are `highspy` and NumPy.
26
+
27
+ ## Installation
28
+
29
+ ```sh
30
+ pip install turfLP
31
+ ```
32
+
33
+ Python 3.10 or later. The package is `turfLP` on PyPI, and the module is `turflp` (`import turflp`). To install the development version from the repository:
34
+
35
+ ```sh
36
+ pip install "git+https://github.com/aigorahub/turfLP.git#subdirectory=python"
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ The input is a matrix with one row per respondent and one column per product: nested lists, a NumPy array, or a pandas data frame. A 1 (or `True`) means that the product reaches the respondent.
42
+
43
+ ```python
44
+ from turflp import turf, turf_sizes, datasets
45
+
46
+ ham = datasets.load("ham") # 127 consumers, 9-point liking of 8 hams
47
+ reach = ham.values >= 7 # top-3 box
48
+ print(turf(reach, 2, names=ham.columns))
49
+ # Portfolio(products=(3, 4), names=('N4', 'S1'), size=2, reach=98, reach_prop=0.7716535433070866,
50
+ # frequency=118, penetration=58.98305084745762, respondents=127)
51
+
52
+ for p in turf_sizes(reach, [1, 2, 3, 4], names=ham.columns):
53
+ print(p.size, p.reach, p.names)
54
+ # 1 60 ('S1',)
55
+ # 2 98 ('N4', 'S1')
56
+ # 3 111 ('N2', 'N3', 'S1')
57
+ # 4 119 ('N1', 'N3', 'N4', 'S1')
58
+ ```
59
+
60
+ ## Functions
61
+
62
+ ```python
63
+ turf(reach, size, tiebreak=("frequency", "penetration"), *,
64
+ names=None, max_pool=1000, max_pool_seconds=30.0) -> Portfolio
65
+ turf_min_cover(reach, *, names=None) -> Portfolio
66
+ turf_sizes(reach, sizes=None, tiebreak=("frequency", "penetration"), *,
67
+ names=None, max_pool=1000, max_pool_seconds=30.0) -> list[Portfolio]
68
+ ```
69
+
70
+ - `turf` finds the `size` products that reach the most respondents. Ties on reach are broken by `tiebreak`, in order: `"frequency"` (the sum of the individual reaches) and `"penetration"` (the harmonic mean of the individual reaches). An empty `tiebreak` means reach only.
71
+ - `turf_min_cover` finds the fewest products that together reach every respondent that some product reaches.
72
+ - `turf_sizes` solves several sizes. The default is every size from 1 to the minimum cover size.
73
+ - `Portfolio` is a frozen dataclass with `products`, `names`, `size`, `reach`, `reach_prop`, `frequency`, `penetration`, and `respondents`.
74
+ - `names` gives the product names; otherwise they come from the data frame columns, or are `P1`, `P2`, and so on.
75
+
76
+ ## Limits and warnings
77
+
78
+ The penetration tie-break is decided in Python, not by the solver: the package collects every portfolio whose penetration can equal the optimum and compares them with the rule in `docs/algorithm.md` section 5. After the first penetration solve, that search examines up to `max_pool` more portfolios (1000 by default) within `max_pool_seconds` seconds (30 by default). If it stops early and frequency comes next, the frequency stage gets another `max_pool_seconds`. When a search stops early, the package emits a `turflp.TurfWarning` that says which result is not proved optimal. `max_pool` must be a whole number of 0 or more and `max_pool_seconds` a number of 0 or more (`math.inf` for no limit); they are checked before any solve. The reach stage and the first penetration solve have no time limit.
79
+
80
+ ## Differences from R
81
+
82
+ - Product indices are 0-based (`products=(3, 4)` above is `c(4, 5)` in R).
83
+ - `turf_sizes` returns a list of `Portfolio` values instead of a data frame.
84
+ - Warnings are `TurfWarning` warnings with the same texts as the R warnings.
85
+ - The limits are keyword arguments instead of the R options `turfLP.max_pool` and `turfLP.max_pool_seconds`.
86
+ - HiGHS presolve is on. The R package turns it off because HiGHS 1.14, which the R `highs` package bundles, returned a wrong optimum with presolve on in a test case. HiGHS 1.15 solves that case correctly.
87
+ - There is no `turf_simulate()`.
88
+
89
+ When several portfolios are optimal, R, Python, and JavaScript can return different ones, with the same values of the requested criteria. When a search stops early, the warning says which value is not proved optimal.
90
+
91
+ ## Data sets
92
+
93
+ `turflp.datasets.load(name)` returns a `Dataset(columns, values)` with the raw responses of one of the seven example data sets of the R package: `cafe`, `chips`, `coffee`, `ham`, `icecream`, `lunchbags`, and `pies`. `ham`, `pies`, and `lunchbags` are under CC BY 4.0 and `coffee` under CC0 1.0; see `COPYRIGHTS`.
94
+
95
+ ## License
96
+
97
+ MIT. See `LICENSE`. The data licenses are in `COPYRIGHTS`.
turflp-0.2.0/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # turflp for Python
2
+
3
+ TURF analysis (total unduplicated reach and frequency) with integer linear programming. This is the Python port of the [turfLP R package](https://github.com/aigorahub/turfLP). It uses the same algorithm, solver settings, limits, and warning texts, and it passes the same conformance suite (`conformance/` in the repository), whose expected values come from exact enumeration of every portfolio.
4
+
5
+ The solver is [HiGHS](https://highs.dev) through `highspy`. The dependencies are `highspy` and NumPy.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pip install turfLP
11
+ ```
12
+
13
+ Python 3.10 or later. The package is `turfLP` on PyPI, and the module is `turflp` (`import turflp`). To install the development version from the repository:
14
+
15
+ ```sh
16
+ pip install "git+https://github.com/aigorahub/turfLP.git#subdirectory=python"
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ The input is a matrix with one row per respondent and one column per product: nested lists, a NumPy array, or a pandas data frame. A 1 (or `True`) means that the product reaches the respondent.
22
+
23
+ ```python
24
+ from turflp import turf, turf_sizes, datasets
25
+
26
+ ham = datasets.load("ham") # 127 consumers, 9-point liking of 8 hams
27
+ reach = ham.values >= 7 # top-3 box
28
+ print(turf(reach, 2, names=ham.columns))
29
+ # Portfolio(products=(3, 4), names=('N4', 'S1'), size=2, reach=98, reach_prop=0.7716535433070866,
30
+ # frequency=118, penetration=58.98305084745762, respondents=127)
31
+
32
+ for p in turf_sizes(reach, [1, 2, 3, 4], names=ham.columns):
33
+ print(p.size, p.reach, p.names)
34
+ # 1 60 ('S1',)
35
+ # 2 98 ('N4', 'S1')
36
+ # 3 111 ('N2', 'N3', 'S1')
37
+ # 4 119 ('N1', 'N3', 'N4', 'S1')
38
+ ```
39
+
40
+ ## Functions
41
+
42
+ ```python
43
+ turf(reach, size, tiebreak=("frequency", "penetration"), *,
44
+ names=None, max_pool=1000, max_pool_seconds=30.0) -> Portfolio
45
+ turf_min_cover(reach, *, names=None) -> Portfolio
46
+ turf_sizes(reach, sizes=None, tiebreak=("frequency", "penetration"), *,
47
+ names=None, max_pool=1000, max_pool_seconds=30.0) -> list[Portfolio]
48
+ ```
49
+
50
+ - `turf` finds the `size` products that reach the most respondents. Ties on reach are broken by `tiebreak`, in order: `"frequency"` (the sum of the individual reaches) and `"penetration"` (the harmonic mean of the individual reaches). An empty `tiebreak` means reach only.
51
+ - `turf_min_cover` finds the fewest products that together reach every respondent that some product reaches.
52
+ - `turf_sizes` solves several sizes. The default is every size from 1 to the minimum cover size.
53
+ - `Portfolio` is a frozen dataclass with `products`, `names`, `size`, `reach`, `reach_prop`, `frequency`, `penetration`, and `respondents`.
54
+ - `names` gives the product names; otherwise they come from the data frame columns, or are `P1`, `P2`, and so on.
55
+
56
+ ## Limits and warnings
57
+
58
+ The penetration tie-break is decided in Python, not by the solver: the package collects every portfolio whose penetration can equal the optimum and compares them with the rule in `docs/algorithm.md` section 5. After the first penetration solve, that search examines up to `max_pool` more portfolios (1000 by default) within `max_pool_seconds` seconds (30 by default). If it stops early and frequency comes next, the frequency stage gets another `max_pool_seconds`. When a search stops early, the package emits a `turflp.TurfWarning` that says which result is not proved optimal. `max_pool` must be a whole number of 0 or more and `max_pool_seconds` a number of 0 or more (`math.inf` for no limit); they are checked before any solve. The reach stage and the first penetration solve have no time limit.
59
+
60
+ ## Differences from R
61
+
62
+ - Product indices are 0-based (`products=(3, 4)` above is `c(4, 5)` in R).
63
+ - `turf_sizes` returns a list of `Portfolio` values instead of a data frame.
64
+ - Warnings are `TurfWarning` warnings with the same texts as the R warnings.
65
+ - The limits are keyword arguments instead of the R options `turfLP.max_pool` and `turfLP.max_pool_seconds`.
66
+ - HiGHS presolve is on. The R package turns it off because HiGHS 1.14, which the R `highs` package bundles, returned a wrong optimum with presolve on in a test case. HiGHS 1.15 solves that case correctly.
67
+ - There is no `turf_simulate()`.
68
+
69
+ When several portfolios are optimal, R, Python, and JavaScript can return different ones, with the same values of the requested criteria. When a search stops early, the warning says which value is not proved optimal.
70
+
71
+ ## Data sets
72
+
73
+ `turflp.datasets.load(name)` returns a `Dataset(columns, values)` with the raw responses of one of the seven example data sets of the R package: `cafe`, `chips`, `coffee`, `ham`, `icecream`, `lunchbags`, and `pies`. `ham`, `pies`, and `lunchbags` are under CC BY 4.0 and `coffee` under CC0 1.0; see `COPYRIGHTS`.
74
+
75
+ ## License
76
+
77
+ MIT. See `LICENSE`. The data licenses are in `COPYRIGHTS`.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "turfLP"
7
+ version = "0.2.0"
8
+ description = "TURF analysis (total unduplicated reach and frequency) with integer linear programming"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE", "COPYRIGHTS"]
12
+ authors = [{ name = "John Ennis", email = "john.m.ennis@aigora.com" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["highspy>=1.15.1,<1.16", "numpy>=1.23"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Scientific/Engineering :: Mathematics",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest>=8", "pandas>=2"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/aigorahub/turfLP"
25
+ Source = "https://github.com/aigorahub/turfLP/tree/main/python"
26
+
27
+ [tool.hatch.build.targets.sdist]
28
+ include = ["src/turflp", "tests", "README.md", "LICENSE", "COPYRIGHTS"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/turflp"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ filterwarnings = ["error::turflp.TurfWarning"]
@@ -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"
@@ -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]