exact-kmeans 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Algorithms and Data Structures Group of the Heinrich Heine University Düsseldorf
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.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.1
2
+ Name: exact-kmeans
3
+ Version: 0.1.0
4
+ Summary: Computes an optimal k-means solution for small instances and small k.
5
+ License: LICENSE
6
+ Author: Melanie Schmidt
7
+ Author-email: mschmidt@hhu.de
8
+ Requires-Python: >=3.8.1,<4.0.0
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Requires-Dist: gurobipy (>=11.0.3,<12.0.0) ; python_version >= "3.9"
15
+ Requires-Dist: gurobipy (>=9.5.1,<10.0.0) ; python_version < "3.9"
16
+ Requires-Dist: numpy (>=1.24.4,<2.0.0) ; python_version < "3.12"
17
+ Requires-Dist: numpy (>=1.26.4,<2.0.0) ; python_version >= "3.12"
18
+ Requires-Dist: pandas (>=2.0.3,<3.0.0)
19
+ Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
20
+ Requires-Dist: scikit-learn (>=1.3.2,<2.0.0) ; python_version < "3.12"
21
+ Requires-Dist: scikit-learn (>=1.4.2,<2.0.0) ; python_version >= "3.12"
22
+ Requires-Dist: tqdm (>=4.66.4,<5.0.0)
23
+ Description-Content-Type: text/markdown
24
+
25
+ [![Build Status](https://github.com/algo-hhu/exact-kmeans/actions/workflows/mypy-flake-test.yml/badge.svg)](https://github.com/algo-hhu/exact-kmeans/actions)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
27
+ [![Supported Python version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/release/python-380/)
28
+ [![Stable Version](https://img.shields.io/pypi/v/exact-kmeans?label=stable)](https://pypi.org/project/exact-kmeans/)
29
+
30
+ # exact-kmeans
31
+
32
+ This package computes exact solutions to the $k$-means problem using integer linear programming (ILP).
33
+ Since our plain ILP solution to the problem was too slow, we implemented a branch-and-bound algorithm that computes multiple ILP solutions for different cluster sizes to find the optimal cluster sizes $[c_1, \ldots, c_k]$.
34
+ The ILPs are implemented using [Gurobi](https://www.gurobi.com/). You will need a [Gurobi license](https://www.gurobi.com/downloads/) to be able to run the code.
35
+
36
+ The algorithm works as follows:
37
+ - We run $k$-means++ on the data to get an initial upper bound for the cost of an optimal solution.
38
+ - We try to find the largest cluster size $c_1$ that a cluster in the optimal solution may have. We use the fact that the cost of a cluster in the optimal solution is less than the initial upper bound. We do this by solving an ILP (ILP#1).
39
+ - We iterate through all possible cluster sizes $c_1 \geq c_2 \ldots \geq c_k$ and compute for each of them a solution using an ILP (ILP#2). However, we don't actually iterate through all the sizes, but use a branch-and-bound algorithm to skip some of them if the cost exceeds the initial upper bound. You can see an example of this in the tree in the "Plot the Branch and Bound Tree" section.
40
+
41
+ Both ILPs get cluster sizes $c_1,\ldots, c_i$ with $i\leq k$ as input and compute an optimal solution to the following modification of the $k$-means problem:
42
+ - the number of clusters is $i$
43
+ - the clusters have sizes $c_1,\ldots, c_i$
44
+ - if $n$ is the number of data points $n-c_1-\ldots -c_i$ points are not part of any cluster
45
+
46
+ ILP#1 gets one single cluster size $c_1$ as input and computes an optimal solution with one single cluster of size $c_1$. We use ILP#1 to compute a lower bound to the cost of a cluster of size $c_1$. If this cost is already larger than the initial upper bound, then we know that the cluster sizes of an optimal solution must be all less than $c_1$. Therefore we can discard all cluster sizes greater equal $c_1$ when searching for the cluster sizes of an optimal solution.
47
+
48
+ ILP#2 gets cluster sizes $c_1,\ldots, c_i$ with $2\leq i\leq k$ as input. If $c_1,\ldots, c_i$ are also cluster sizes which are represented in the optimal solution the cost that ILP#2 computes should be less than the initial upper bound. We add a constraint to ILP#2 bounding the cost by initial upper bound. Therefore either ILP#2 is infeasible or it outputs an optimal solution of $i$ clusters with sizes $c_1,\ldots, c_i$ and cost smaller than initial upper bound.
49
+
50
+ To search for the cluster sizes of an optimal solution we use a branch and bound approach. In a branch node of level $i$ the $i$ largest cluster sizes $c_1, \ldots, c_i$ are already fixed. The variables `branching_levels` and `fill_cluster_sizes` define the behavior on these branching nodes. If `branching_levels` is greater equal to the level $i$ of the node then we use ILP#2 to bound the current cost and decide if we branch, otherwise we always branch on this node. If the variable `fill_cluster_sizes` is set to true we compute the smallest possible remaining cluster sizes $c_{i+1},\ldots, c_{k}$ and run ILP#2 with cluster sizes $c_1,\ldots, c_k$. If the variable `fill_cluster_sizes` is set to false we run ILP#2 only with the fixed cluster sizes $c_1,\ldots, c_i$. Setting `fill_cluster_sizes` to true may lead to less branching but can increase the solving time of ILP#2.
51
+
52
+ To customize the runs, you can create a config file. The default config file is [`config/default.yaml`](config/default.yaml). You can also pass a different config file as an argument.
53
+ - `num_processes` (integer or float) sets the number of processes used. The algorithm was parallelized using the `multiprocessing` package, so you can set the number of processes that you want to use. If you use an integer, at most that number of processes will be taken, otherwise if you use a float, it will be a fraction of the available CPUs. If the parameter is not passed, the algorithm will use all available CPUs.
54
+ - `bound_model_params` are the arguments that are passed to the ILP#1 model. Please have a look at the [Gurobi documentation](https://www.gurobi.com/documentation/9.1/refman/parameters.html) for more information.
55
+ - `model_params` are the arguments that are passed to the ILP#2 model. Please have a look at the [Gurobi documentation](https://www.gurobi.com/documentation/9.1/refman/parameters.html) for more information.
56
+ - `branching_priorities` (true/false) enables the use of branching priorities. If true, the priority of the $x$ variable will be higher than the other variables. According to our tests, this speeds up the solving.
57
+ - `replace_min` (true/false) replaces a minimum constraint with a linear version. According to our tests, this speeds up the solving.
58
+ - `branching_levels` (integer) Sets the maximum level of a branching node where we run ILP#2 to bound the cost. Since this can increase the number of ILPs that are computed, it may only make sense for small levels.
59
+ - `fill_cluster_sizes` (true/false) If set to false we run ILP#2 only with cluster sizes fixed at the branching node. If set to true we fill the cluster sizes up to $k$. Setting the variable to true can result in a larger computation time for ILP#2 but may result in less branching, which can save time. For small $k$ we recommend setting it to false and for large $k$ we recommend setting it to true.
60
+
61
+
62
+ ## Installation
63
+
64
+ For this package to work, you need to have a working installation of [Gurobi](https://www.gurobi.com/). You can get a free academic license [here](https://www.gurobi.com/academia/academic-program-and-licenses/).
65
+ [Here](https://support.gurobi.com/hc/en-us/articles/4534161999889-How-do-I-install-Gurobi-Optimizer) you can find a guide on how to install it.
66
+
67
+ ```bash
68
+ pip install exact-kmeans
69
+ ```
70
+
71
+ ## Use within Python
72
+
73
+ ```python
74
+ from exact_kmeans import ExactKMeans
75
+ from exact_kmeans.util import JsonEncoder
76
+ from ucimlrepo import fetch_ucirepo
77
+
78
+ # For example, you can import data from the UCI repository
79
+ # but you can also use your own dataset
80
+ iris = fetch_ucirepo(id=53)
81
+
82
+ X = iris.data.features
83
+
84
+ ilp = ExactKMeans(X=X, k=3)
85
+ res = ilp.optimize()
86
+
87
+ with open("output.json", "w") as f:
88
+ json.dump(res, f, indent=4, cls=JsonEncoder)
89
+ ```
90
+
91
+ ## Command Line
92
+
93
+ Install [poetry](https://python-poetry.org/docs/#installation)
94
+ ```bash
95
+ curl -sSL https://install.python-poetry.org | python3 -
96
+ ```
97
+
98
+ Install the package
99
+ ```bash
100
+ poetry install
101
+ ```
102
+
103
+ Run the program
104
+ ```bash
105
+ poetry run python -m exact_kmeans --data-path iris.csv --verbose --results-path test/iris.json --k 3 --config-file config/default.yaml
106
+ ```
107
+
108
+ Your `data-path` should be a file with a header containing only the comma-separated data points. Example:
109
+ ```csv
110
+ 0,1,2,3
111
+ 5.1,3.5,1.4,0.2
112
+ 4.9,3.0,1.4,0.2
113
+ 4.7,3.2,1.3,0.2
114
+ 4.6,3.1,1.5,0.2
115
+ 5.0,3.6,1.4,0.2
116
+ ...
117
+ ```
118
+
119
+ ## Plot the Branch and Bound Tree
120
+
121
+ Assuming that you have run the program and stored the results in a JSON file, you can plot the tree produced by the algorithm.
122
+
123
+ ```bash
124
+ poetry run python plot_tree.py --output-json output.json --plot-folder plots
125
+ ```
126
+
127
+ This will create a tree that looks like this:
128
+ <p align="center">
129
+ <img src="https://raw.githubusercontent.com/algo-hhu/exact-kmeans/main/images/tree.png" alt="Tree Example"/>
130
+ </p>
131
+
132
+ The nodes can have the following colors:
133
+ - Red: The cluster sizes were infeasible.
134
+ - Green: The cluster sizes were feasible.
135
+ - Gray: The node was a branching node.
136
+ - Orange/Yellow: The node was skipped because it had a too high initial cost.
137
+ - The optimal node has a gold border.
138
+
139
+ The nodes have the following information:
140
+ - The cluster sizes that were tested.
141
+ - The amount of seconds it took.
142
+ - The cost of the solution. This is only shown if the node has $k$ clusters and is feasible.
143
+
144
+ ## Development
145
+
146
+ Install [poetry](https://python-poetry.org/docs/#installation)
147
+ ```bash
148
+ curl -sSL https://install.python-poetry.org | python3 -
149
+ ```
150
+
151
+ Install the package
152
+ ```bash
153
+ poetry install
154
+ ```
155
+
156
+ Run the tests
157
+ ```bash
158
+ poetry run python -m unittest discover tests -v
159
+ ```
160
+
161
+ ## Disclaimer
162
+
163
+ This package requires the Gurobi Optimizer, which is a commercial optimization solver. Users must obtain their own licenses for Gurobi.
164
+ Gurobi License: You are responsible for complying with all Gurobi licensing terms. For more information, visit the [Gurobi Licensing Page](https://www.gurobi.com/academia/academic-program-and-licenses/).
165
+
166
+ <!--
167
+ ## Citation
168
+
169
+ If you use this code, please cite [the following paper]():
170
+
171
+ ```
172
+ ```
173
+ -->
174
+
@@ -0,0 +1,149 @@
1
+ [![Build Status](https://github.com/algo-hhu/exact-kmeans/actions/workflows/mypy-flake-test.yml/badge.svg)](https://github.com/algo-hhu/exact-kmeans/actions)
2
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ [![Supported Python version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/release/python-380/)
4
+ [![Stable Version](https://img.shields.io/pypi/v/exact-kmeans?label=stable)](https://pypi.org/project/exact-kmeans/)
5
+
6
+ # exact-kmeans
7
+
8
+ This package computes exact solutions to the $k$-means problem using integer linear programming (ILP).
9
+ Since our plain ILP solution to the problem was too slow, we implemented a branch-and-bound algorithm that computes multiple ILP solutions for different cluster sizes to find the optimal cluster sizes $[c_1, \ldots, c_k]$.
10
+ The ILPs are implemented using [Gurobi](https://www.gurobi.com/). You will need a [Gurobi license](https://www.gurobi.com/downloads/) to be able to run the code.
11
+
12
+ The algorithm works as follows:
13
+ - We run $k$-means++ on the data to get an initial upper bound for the cost of an optimal solution.
14
+ - We try to find the largest cluster size $c_1$ that a cluster in the optimal solution may have. We use the fact that the cost of a cluster in the optimal solution is less than the initial upper bound. We do this by solving an ILP (ILP#1).
15
+ - We iterate through all possible cluster sizes $c_1 \geq c_2 \ldots \geq c_k$ and compute for each of them a solution using an ILP (ILP#2). However, we don't actually iterate through all the sizes, but use a branch-and-bound algorithm to skip some of them if the cost exceeds the initial upper bound. You can see an example of this in the tree in the "Plot the Branch and Bound Tree" section.
16
+
17
+ Both ILPs get cluster sizes $c_1,\ldots, c_i$ with $i\leq k$ as input and compute an optimal solution to the following modification of the $k$-means problem:
18
+ - the number of clusters is $i$
19
+ - the clusters have sizes $c_1,\ldots, c_i$
20
+ - if $n$ is the number of data points $n-c_1-\ldots -c_i$ points are not part of any cluster
21
+
22
+ ILP#1 gets one single cluster size $c_1$ as input and computes an optimal solution with one single cluster of size $c_1$. We use ILP#1 to compute a lower bound to the cost of a cluster of size $c_1$. If this cost is already larger than the initial upper bound, then we know that the cluster sizes of an optimal solution must be all less than $c_1$. Therefore we can discard all cluster sizes greater equal $c_1$ when searching for the cluster sizes of an optimal solution.
23
+
24
+ ILP#2 gets cluster sizes $c_1,\ldots, c_i$ with $2\leq i\leq k$ as input. If $c_1,\ldots, c_i$ are also cluster sizes which are represented in the optimal solution the cost that ILP#2 computes should be less than the initial upper bound. We add a constraint to ILP#2 bounding the cost by initial upper bound. Therefore either ILP#2 is infeasible or it outputs an optimal solution of $i$ clusters with sizes $c_1,\ldots, c_i$ and cost smaller than initial upper bound.
25
+
26
+ To search for the cluster sizes of an optimal solution we use a branch and bound approach. In a branch node of level $i$ the $i$ largest cluster sizes $c_1, \ldots, c_i$ are already fixed. The variables `branching_levels` and `fill_cluster_sizes` define the behavior on these branching nodes. If `branching_levels` is greater equal to the level $i$ of the node then we use ILP#2 to bound the current cost and decide if we branch, otherwise we always branch on this node. If the variable `fill_cluster_sizes` is set to true we compute the smallest possible remaining cluster sizes $c_{i+1},\ldots, c_{k}$ and run ILP#2 with cluster sizes $c_1,\ldots, c_k$. If the variable `fill_cluster_sizes` is set to false we run ILP#2 only with the fixed cluster sizes $c_1,\ldots, c_i$. Setting `fill_cluster_sizes` to true may lead to less branching but can increase the solving time of ILP#2.
27
+
28
+ To customize the runs, you can create a config file. The default config file is [`config/default.yaml`](config/default.yaml). You can also pass a different config file as an argument.
29
+ - `num_processes` (integer or float) sets the number of processes used. The algorithm was parallelized using the `multiprocessing` package, so you can set the number of processes that you want to use. If you use an integer, at most that number of processes will be taken, otherwise if you use a float, it will be a fraction of the available CPUs. If the parameter is not passed, the algorithm will use all available CPUs.
30
+ - `bound_model_params` are the arguments that are passed to the ILP#1 model. Please have a look at the [Gurobi documentation](https://www.gurobi.com/documentation/9.1/refman/parameters.html) for more information.
31
+ - `model_params` are the arguments that are passed to the ILP#2 model. Please have a look at the [Gurobi documentation](https://www.gurobi.com/documentation/9.1/refman/parameters.html) for more information.
32
+ - `branching_priorities` (true/false) enables the use of branching priorities. If true, the priority of the $x$ variable will be higher than the other variables. According to our tests, this speeds up the solving.
33
+ - `replace_min` (true/false) replaces a minimum constraint with a linear version. According to our tests, this speeds up the solving.
34
+ - `branching_levels` (integer) Sets the maximum level of a branching node where we run ILP#2 to bound the cost. Since this can increase the number of ILPs that are computed, it may only make sense for small levels.
35
+ - `fill_cluster_sizes` (true/false) If set to false we run ILP#2 only with cluster sizes fixed at the branching node. If set to true we fill the cluster sizes up to $k$. Setting the variable to true can result in a larger computation time for ILP#2 but may result in less branching, which can save time. For small $k$ we recommend setting it to false and for large $k$ we recommend setting it to true.
36
+
37
+
38
+ ## Installation
39
+
40
+ For this package to work, you need to have a working installation of [Gurobi](https://www.gurobi.com/). You can get a free academic license [here](https://www.gurobi.com/academia/academic-program-and-licenses/).
41
+ [Here](https://support.gurobi.com/hc/en-us/articles/4534161999889-How-do-I-install-Gurobi-Optimizer) you can find a guide on how to install it.
42
+
43
+ ```bash
44
+ pip install exact-kmeans
45
+ ```
46
+
47
+ ## Use within Python
48
+
49
+ ```python
50
+ from exact_kmeans import ExactKMeans
51
+ from exact_kmeans.util import JsonEncoder
52
+ from ucimlrepo import fetch_ucirepo
53
+
54
+ # For example, you can import data from the UCI repository
55
+ # but you can also use your own dataset
56
+ iris = fetch_ucirepo(id=53)
57
+
58
+ X = iris.data.features
59
+
60
+ ilp = ExactKMeans(X=X, k=3)
61
+ res = ilp.optimize()
62
+
63
+ with open("output.json", "w") as f:
64
+ json.dump(res, f, indent=4, cls=JsonEncoder)
65
+ ```
66
+
67
+ ## Command Line
68
+
69
+ Install [poetry](https://python-poetry.org/docs/#installation)
70
+ ```bash
71
+ curl -sSL https://install.python-poetry.org | python3 -
72
+ ```
73
+
74
+ Install the package
75
+ ```bash
76
+ poetry install
77
+ ```
78
+
79
+ Run the program
80
+ ```bash
81
+ poetry run python -m exact_kmeans --data-path iris.csv --verbose --results-path test/iris.json --k 3 --config-file config/default.yaml
82
+ ```
83
+
84
+ Your `data-path` should be a file with a header containing only the comma-separated data points. Example:
85
+ ```csv
86
+ 0,1,2,3
87
+ 5.1,3.5,1.4,0.2
88
+ 4.9,3.0,1.4,0.2
89
+ 4.7,3.2,1.3,0.2
90
+ 4.6,3.1,1.5,0.2
91
+ 5.0,3.6,1.4,0.2
92
+ ...
93
+ ```
94
+
95
+ ## Plot the Branch and Bound Tree
96
+
97
+ Assuming that you have run the program and stored the results in a JSON file, you can plot the tree produced by the algorithm.
98
+
99
+ ```bash
100
+ poetry run python plot_tree.py --output-json output.json --plot-folder plots
101
+ ```
102
+
103
+ This will create a tree that looks like this:
104
+ <p align="center">
105
+ <img src="https://raw.githubusercontent.com/algo-hhu/exact-kmeans/main/images/tree.png" alt="Tree Example"/>
106
+ </p>
107
+
108
+ The nodes can have the following colors:
109
+ - Red: The cluster sizes were infeasible.
110
+ - Green: The cluster sizes were feasible.
111
+ - Gray: The node was a branching node.
112
+ - Orange/Yellow: The node was skipped because it had a too high initial cost.
113
+ - The optimal node has a gold border.
114
+
115
+ The nodes have the following information:
116
+ - The cluster sizes that were tested.
117
+ - The amount of seconds it took.
118
+ - The cost of the solution. This is only shown if the node has $k$ clusters and is feasible.
119
+
120
+ ## Development
121
+
122
+ Install [poetry](https://python-poetry.org/docs/#installation)
123
+ ```bash
124
+ curl -sSL https://install.python-poetry.org | python3 -
125
+ ```
126
+
127
+ Install the package
128
+ ```bash
129
+ poetry install
130
+ ```
131
+
132
+ Run the tests
133
+ ```bash
134
+ poetry run python -m unittest discover tests -v
135
+ ```
136
+
137
+ ## Disclaimer
138
+
139
+ This package requires the Gurobi Optimizer, which is a commercial optimization solver. Users must obtain their own licenses for Gurobi.
140
+ Gurobi License: You are responsible for complying with all Gurobi licensing terms. For more information, visit the [Gurobi Licensing Page](https://www.gurobi.com/academia/academic-program-and-licenses/).
141
+
142
+ <!--
143
+ ## Citation
144
+
145
+ If you use this code, please cite [the following paper]():
146
+
147
+ ```
148
+ ```
149
+ -->
@@ -0,0 +1,29 @@
1
+ ilp_version: v4
2
+
3
+ num_processes: 0.5
4
+ branch_priorities: true
5
+ replace_min: true
6
+ branching_ilp: true
7
+ branching_levels: 2
8
+ fill_cluster_sizes: false
9
+
10
+ model_params:
11
+ TimeLimit: 28800
12
+ BarHomogeneous: 1
13
+ BarConvTol: 0.001
14
+ FeasibilityTol: 0.001
15
+ # Method: 2
16
+ # Crossover: 0
17
+ # NodeMethod: 2
18
+
19
+ bound_model_params:
20
+ TimeLimit: 1800
21
+ BarConvTol: 0.0001
22
+ FeasibilityTol: 0.0001
23
+ # Concurrent spin time: 9.02s (can be avoided by choosing Method=3)
24
+ Method: 3
25
+
26
+ # https://support.gurobi.com/hc/en-us/community/posts/4406728832145-Crossover-Takes-Long
27
+ # Method: 2
28
+ # Crossover: 0
29
+ # NodeMethod: 2
@@ -0,0 +1,3 @@
1
+ from exact_kmeans.ilp import ExactKMeans
2
+
3
+ __all__ = ["ExactKMeans"]
@@ -0,0 +1,110 @@
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ from time import time
7
+
8
+ import pandas as pd
9
+
10
+ from exact_kmeans.ilp import ExactKMeans
11
+ from exact_kmeans.util import JsonEncoder, get_git_hash
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def set_up_logger(log_file: Path, mode: str = "w+") -> None:
17
+ log_file.parent.mkdir(parents=True, exist_ok=True)
18
+ formatter = logging.Formatter(
19
+ "[%(asctime)s: %(levelname)s/%(filename)s:%(lineno)d] %(message)s"
20
+ )
21
+
22
+ stdout_handler = logging.StreamHandler(sys.stdout)
23
+ stdout_handler.setLevel(logging.DEBUG)
24
+ stdout_handler.setFormatter(formatter)
25
+
26
+ file_handler = logging.FileHandler(log_file, mode=mode)
27
+ file_handler.setLevel(logging.DEBUG)
28
+ file_handler.setFormatter(formatter)
29
+
30
+ logger = logging.getLogger()
31
+
32
+ logger.addHandler(file_handler)
33
+ logger.addHandler(stdout_handler)
34
+
35
+
36
+ if __name__ == "__main__":
37
+
38
+ parser = argparse.ArgumentParser()
39
+
40
+ parser.add_argument("--k", type=int, required=True)
41
+ parser.add_argument("--data-path", type=Path, required=True)
42
+ parser.add_argument("--config-file", type=Path, default="config/default.yaml")
43
+ parser.add_argument("--kmeans-iterations", type=int, default=100)
44
+ parser.add_argument("--results-path", type=Path, default=None)
45
+ parser.add_argument("--load-existing-run-path", type=Path, default=None)
46
+ parser.add_argument("--cache-current-run-path", type=Path, default=None)
47
+
48
+ parser.add_argument("--verbose", action="store_true")
49
+
50
+ args = parser.parse_args()
51
+
52
+ set_up_logger(
53
+ args.results_path.parent / args.results_path.name.replace(".json", ".log"),
54
+ )
55
+
56
+ if args.verbose:
57
+ logging.getLogger().setLevel(logging.INFO)
58
+ else:
59
+ logging.getLogger().setLevel(logging.WARNING)
60
+
61
+ data = pd.read_csv(args.data_path)
62
+ X = data.values
63
+ logger.info(f"The data has the following shape: {X.shape}")
64
+
65
+ ilp = ExactKMeans(
66
+ X=X,
67
+ k=args.k,
68
+ config_file=args.config_file,
69
+ load_existing_run_path=args.load_existing_run_path,
70
+ cache_current_run_path=args.cache_current_run_path,
71
+ kmeans_iterations=args.kmeans_iterations,
72
+ )
73
+
74
+ start = time()
75
+ res = ilp.optimize()
76
+ ilp_time = time() - start
77
+
78
+ if args.results_path is not None:
79
+ args.results_path.parent.mkdir(parents=True, exist_ok=True)
80
+ model = res["model"]
81
+ res_eval = {
82
+ "version": ilp.ilp_version,
83
+ "time": ilp_time,
84
+ "initial_labels": res["initial_labels"],
85
+ "labels": res["labels"],
86
+ "objective": res["objective"],
87
+ "cluster_size_objectives": res["cluster_size_objectives"],
88
+ "best_cluster_sizes": res["best_cluster_sizes"],
89
+ "processed_cluster_sizes": res["processed_cluster_sizes"],
90
+ "changed_model_params": ilp.changed_model_params,
91
+ "changed_bound_params": ilp.changed_bound_model_params,
92
+ "optimal": model.Status == 2,
93
+ "git_hash": get_git_hash(),
94
+ }
95
+ for var_name, var in [
96
+ ("gap", "MIPGap"),
97
+ ("num_constraints", "NumConstrs"),
98
+ ("num_variables", "NumVars"),
99
+ ("upper_bound", "ObjVal"),
100
+ ("lower_bound", "ObjBound"),
101
+ ("solver_time", "Runtime"),
102
+ ]:
103
+ res_eval[var_name] = getattr(model, var)
104
+
105
+ with open(args.results_path, "w") as f:
106
+ json.dump(res_eval, f, indent=4, cls=JsonEncoder)
107
+
108
+ ilp.print_model(
109
+ args.results_path.parent, args.results_path.name.replace(".json", "")
110
+ )
@@ -0,0 +1,60 @@
1
+ import logging
2
+
3
+ import gurobipy as gp
4
+ import numpy as np
5
+ from gurobipy import GRB
6
+
7
+ from exact_kmeans.util import print_variables
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def add_cuts(model: gp.Model, where: int, v: int, n: int, k: int) -> None:
13
+ if where == GRB.Callback.MIPNODE:
14
+ status = model.cbGet(GRB.Callback.MIPNODE_STATUS)
15
+ if status != GRB.OPTIMAL:
16
+ return
17
+ for i in range(v, n):
18
+ for j in range(i + 1, n):
19
+ for ll in range(v, k):
20
+ zs = [
21
+ model.getVarByName(f"z[{i},{j},{ll},{m}]") for m in range(v, n)
22
+ ]
23
+ relaxed_z = model.cbGetNodeRel(zs) # type: ignore
24
+ y = model.getVarByName(f"y[{i},{j},{ll}]")
25
+ if not np.isclose(
26
+ sum(relaxed_z), model.cbGetNodeRel(y), atol=0.001 # type: ignore
27
+ ):
28
+ logger.info(
29
+ f"Add cut for z[{i},{j},{ll},:] ({sum(relaxed_z)}) "
30
+ "and y[{i},{j},{ll}] ({model.cbGetNodeRel(y)})"
31
+ )
32
+ model.cbCut(gp.quicksum(zs) == y) # type: ignore
33
+
34
+
35
+ def variable_bounds(model: gp.Model, where: int) -> None:
36
+ if where in {GRB.Callback.MIPNODE, GRB.Callback.MIPSOL, GRB.Callback.MIP}:
37
+ for var in model.getVars():
38
+ # This does not work, the lower and upper bounds of the variables are never updated?
39
+ if var.LB != 0 or var.UB != 1:
40
+ print()
41
+ print(var, var.LB, var.UB)
42
+ print()
43
+
44
+
45
+ def relax_callback(model: gp.Model, where: int) -> None:
46
+ if where == GRB.Callback.MIPNODE:
47
+ status = model.cbGet(GRB.Callback.MIPNODE_STATUS)
48
+ if status == GRB.OPTIMAL:
49
+ with open("relaxed-example.txt", "w") as of:
50
+ for txt in print_variables(
51
+ model.getVars(), model.cbGetNodeRel(model.getVars())
52
+ ):
53
+ of.write(txt)
54
+ # dd = {
55
+ # var.VarName: val
56
+ # for var, val in zip(
57
+ # model.getVars(), model.cbGetNodeRel(model.getVars())
58
+ # )
59
+ # }
60
+ model.terminate()
@@ -0,0 +1,3 @@
1
+ from exact_kmeans.dynamic_program.dp_plain import compute_bounds
2
+
3
+ __all__ = ["compute_bounds"]
@@ -0,0 +1,98 @@
1
+ import math
2
+ from typing import Dict
3
+
4
+ import numpy as np
5
+
6
+
7
+ def DP_init(n_points: int, k: int, lb: Dict[int, float]) -> np.ndarray:
8
+ # make array one dimension larger to avoid substracting 1 every time
9
+ r = np.zeros(shape=(n_points + 1, k + 1), dtype=float)
10
+
11
+ r[0][0] = 0
12
+ r[1][0] = math.inf
13
+ r[0][1] = math.inf
14
+
15
+ for covered in range(1, n_points + 1):
16
+ if covered in lb:
17
+ r[covered][1] = lb[covered]
18
+ else:
19
+ r[covered][1] = math.inf
20
+ r[covered][0] = math.inf
21
+
22
+ for clusters in range(2, k + 1):
23
+ r[0][clusters] = math.inf
24
+ r[1][clusters] = math.inf
25
+
26
+ # print(r)
27
+ return r
28
+
29
+
30
+ def DP_recursion(n_points: int, k: int, lb: Dict[int, float], r: np.ndarray) -> None:
31
+ for covered in range(2, n_points + 1):
32
+ for clusters in range(2, k + 1):
33
+ max_size = min(covered - clusters + 1, n_points - k + 1)
34
+ # print(f"Covered: {covered}, Clusters: {clusters}, max_size= {max_size}")
35
+ min_curr = math.inf
36
+
37
+ for cluster_size in range(1, max_size + 1):
38
+ if cluster_size in lb:
39
+ min_new = r[covered - cluster_size][clusters - 1] + lb[cluster_size]
40
+ else:
41
+ min_new = math.inf
42
+
43
+ if min_new < min_curr:
44
+ min_curr = min_new
45
+
46
+ r[covered][clusters] = min_curr
47
+
48
+
49
+ def DP_recursion_print_part(
50
+ n_points: int, k: int, lb: Dict[int, float], r: np.ndarray
51
+ ) -> None:
52
+ partition = {(i, 1): [i] for i in range(0, n_points + 1)}
53
+
54
+ for covered in range(2, n_points + 1):
55
+ for clusters in range(2, k + 1):
56
+ max_size = min(covered - clusters + 1, n_points - k + 1)
57
+ # print(f"Covered: {covered}, Clusters: {clusters}, max_size= {max_size}")
58
+ min_curr = math.inf
59
+ min_size = 0
60
+
61
+ for cluster_size in range(1, max_size + 1):
62
+ if cluster_size in lb:
63
+ min_new = r[covered - cluster_size][clusters - 1] + lb[cluster_size]
64
+ else:
65
+ min_new = math.inf
66
+
67
+ if min_new < min_curr:
68
+ min_curr = min_new
69
+ min_size = cluster_size
70
+
71
+ r[covered][clusters] = min_curr
72
+ new_part = partition[(covered - min_size, clusters - 1)][:]
73
+ new_part.append(min_size)
74
+ partition.update({(covered, clusters): new_part})
75
+
76
+ for i in range(1, n_points + 1):
77
+ print(f"Number of points {i}:")
78
+ for j in range(1, min(k + 1, i + 1)):
79
+ curr = partition[(i, j)]
80
+ print(f"{curr} ", end="")
81
+ print("\n")
82
+
83
+
84
+ def compute_bounds(
85
+ n_points: int, k: int, lb: Dict[int, float], print_part: bool = False
86
+ ) -> np.ndarray:
87
+ if n_points == 0 or k == 0:
88
+ raise ValueError(
89
+ f"Number of points (n={n_points}) "
90
+ f"and number of clusters (k={k}) must be greater than 0"
91
+ )
92
+ r = DP_init(n_points, k, lb)
93
+ if print_part:
94
+ DP_recursion_print_part(n_points, k, lb, r)
95
+ else:
96
+ DP_recursion(n_points, k, lb, r)
97
+
98
+ return r