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.
- turflp-0.2.0/.gitignore +24 -0
- turflp-0.2.0/COPYRIGHTS +36 -0
- turflp-0.2.0/LICENSE +21 -0
- turflp-0.2.0/PKG-INFO +97 -0
- turflp-0.2.0/README.md +77 -0
- turflp-0.2.0/pyproject.toml +35 -0
- turflp-0.2.0/src/turflp/__init__.py +8 -0
- turflp-0.2.0/src/turflp/_core.py +321 -0
- turflp-0.2.0/src/turflp/_input.py +109 -0
- turflp-0.2.0/src/turflp/_solver.py +126 -0
- turflp-0.2.0/src/turflp/datasets/__init__.py +33 -0
- turflp-0.2.0/src/turflp/datasets/cafe.csv +2501 -0
- turflp-0.2.0/src/turflp/datasets/chips.csv +601 -0
- turflp-0.2.0/src/turflp/datasets/coffee.csv +119 -0
- turflp-0.2.0/src/turflp/datasets/ham.csv +128 -0
- turflp-0.2.0/src/turflp/datasets/icecream.csv +121 -0
- turflp-0.2.0/src/turflp/datasets/lunchbags.csv +1176 -0
- turflp-0.2.0/src/turflp/datasets/pies.csv +981 -0
- turflp-0.2.0/tests/conftest.py +52 -0
- turflp-0.2.0/tests/test_conformance.py +132 -0
- turflp-0.2.0/tests/test_datasets.py +42 -0
- turflp-0.2.0/tests/test_solver.py +124 -0
- turflp-0.2.0/tests/test_turf.py +227 -0
turflp-0.2.0/.gitignore
ADDED
|
@@ -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/
|
turflp-0.2.0/COPYRIGHTS
ADDED
|
@@ -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]
|