kap 0.2.2__tar.gz → 0.2.3__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.
- {kap-0.2.2 → kap-0.2.3}/PKG-INFO +53 -33
- {kap-0.2.2 → kap-0.2.3}/README.md +34 -17
- kap-0.2.3/kap/_version.py +1 -0
- {kap-0.2.2 → kap-0.2.3}/kap/assignment.py +24 -12
- {kap-0.2.2 → kap-0.2.3}/kap.egg-info/PKG-INFO +53 -33
- {kap-0.2.2 → kap-0.2.3}/kap.egg-info/SOURCES.txt +4 -4
- kap-0.2.3/kap.egg-info/requires.txt +20 -0
- kap-0.2.3/pyproject.toml +44 -0
- kap-0.2.3/setup.cfg +4 -0
- kap-0.2.3/tests/test_k_assignment.py +240 -0
- kap-0.2.3/tests/test_linear_assignment.py +69 -0
- kap-0.2.2/kap/_version.py +0 -1
- kap-0.2.2/kap.egg-info/not-zip-safe +0 -1
- kap-0.2.2/kap.egg-info/requires.txt +0 -1
- kap-0.2.2/setup.cfg +0 -53
- kap-0.2.2/setup.py +0 -22
- {kap-0.2.2 → kap-0.2.3}/LICENSE.txt +0 -0
- {kap-0.2.2 → kap-0.2.3}/kap/__init__.py +0 -0
- {kap-0.2.2 → kap-0.2.3}/kap/_types.py +0 -0
- {kap-0.2.2 → kap-0.2.3}/kap.egg-info/dependency_links.txt +0 -0
- {kap-0.2.2 → kap-0.2.3}/kap.egg-info/top_level.txt +0 -0
{kap-0.2.2 → kap-0.2.3}/PKG-INFO
RENAMED
|
@@ -1,39 +1,42 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kap
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Gabrovšek's Multiple Hungarian method for solving k-Assignment Problem.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Author-email: hnhat.tran@gmail.com
|
|
8
|
-
License: MIT
|
|
5
|
+
Author-email: "Hoang-Nhat Tran (inspiros)" <hnhat.tran@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
9
7
|
Project-URL: Source, https://github.com/inspiros/kap
|
|
8
|
+
Project-URL: Download, https://pypi.org/project/kap
|
|
10
9
|
Keywords: k-assignment,k-partite-matching,linear-assignment,bi-partite-matching,hungarian
|
|
11
10
|
Classifier: Development Status :: 3 - Alpha
|
|
12
11
|
Classifier: Intended Audience :: Developers
|
|
13
12
|
Classifier: Intended Audience :: Education
|
|
14
13
|
Classifier: Intended Audience :: Science/Research
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
-
Classifier: Programming Language :: Python
|
|
17
14
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
26
15
|
Classifier: Operating System :: OS Independent
|
|
27
16
|
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
28
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.9
|
|
29
18
|
Description-Content-Type: text/markdown
|
|
30
19
|
License-File: LICENSE.txt
|
|
31
20
|
Requires-Dist: numpy
|
|
21
|
+
Provides-Extra: scipy
|
|
22
|
+
Requires-Dist: scipy; extra == "scipy"
|
|
23
|
+
Provides-Extra: lap
|
|
24
|
+
Requires-Dist: lap; extra == "lap"
|
|
25
|
+
Provides-Extra: lapjv
|
|
26
|
+
Requires-Dist: lapjv; extra == "lapjv"
|
|
27
|
+
Provides-Extra: munkres
|
|
28
|
+
Requires-Dist: munkres; extra == "munkres"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Requires-Dist: scipy; extra == "test"
|
|
32
|
+
Requires-Dist: lap; extra == "test"
|
|
33
|
+
Requires-Dist: lapjv; extra == "test"
|
|
34
|
+
Requires-Dist: munkres; extra == "test"
|
|
32
35
|
Dynamic: license-file
|
|
33
36
|
|
|
34
37
|
``kap``: $k$-Assignment Problem Solver
|
|
35
38
|
======
|
|
36
|
-
[](https://github.com/inspiros/kap/actions) [](https://pypi.org/project/kap) [](https://pepy.tech/project/kap) [](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [](https://zenodo.org/
|
|
39
|
+
[](https://github.com/inspiros/kap/actions) [](https://pypi.org/project/kap) [](https://pepy.tech/project/kap) [](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [](https://zenodo.org/badge/latestdoi/R_kgDOK5wdCQ)
|
|
37
40
|
|
|
38
41
|
This project implements **Boštjan Gabrovšek**'s Multiple Hungarian Methods for solving the **$k$-Assignment Problem**
|
|
39
42
|
(or **$k$-Partite Graph Matching Problem**), described in [this paper](https://www.mdpi.com/2227-7390/8/11/2050).
|
|
@@ -76,19 +79,22 @@ Further maintenance or performance tuning might be limited, but all contribution
|
|
|
76
79
|
|
|
77
80
|
## Requirements
|
|
78
81
|
|
|
79
|
-
- **Python 3.
|
|
82
|
+
- **Python 3.9+**
|
|
80
83
|
- ``numpy``
|
|
81
|
-
- ``scipy`` or ``lap`` or ``lapjv`` or ``lapsolver`` or ``munkres``
|
|
84
|
+
- ``scipy`` (default backend) or ``lap`` or ``lapjv`` or ``lapsolver`` or ``munkres``
|
|
82
85
|
_(depends on the backend to be used for solving Linear Assignment Problem)_
|
|
83
86
|
|
|
84
87
|
## Installation
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
Install with the default ``scipy`` backend:
|
|
87
90
|
|
|
88
91
|
```
|
|
89
|
-
pip install kap
|
|
92
|
+
pip install kap[scipy]
|
|
90
93
|
```
|
|
91
94
|
|
|
95
|
+
``pip install kap`` alone installs only ``numpy``; then install one of the backends above yourself
|
|
96
|
+
(extras ``kap[lap]``, ``kap[lapjv]`` and ``kap[munkres]`` are also available).
|
|
97
|
+
|
|
92
98
|
#### Notes
|
|
93
99
|
|
|
94
100
|
This is currently a pure Python project, but we may add Cython/C++ extension in the future.
|
|
@@ -126,7 +132,7 @@ matching_result = linear_assignment(cost, return_free=True, backend="lap")
|
|
|
126
132
|
print("Result:", matching_result)
|
|
127
133
|
# Result: AssignmentResult(matches=array([[0, 2],
|
|
128
134
|
# [1, 1],
|
|
129
|
-
# [2, 0]]
|
|
135
|
+
# [2, 0]]), matching_costs=array([4, 4, 3]), free=[])
|
|
130
136
|
print("Total cost:", sum(matching_result.matching_costs))
|
|
131
137
|
# Total cost: 11
|
|
132
138
|
```
|
|
@@ -135,16 +141,17 @@ print("Total cost:", sum(matching_result.matching_costs))
|
|
|
135
141
|
|
|
136
142
|
``kap.k_assignment`` is the equivalence for $k$-Assignment Problem.
|
|
137
143
|
There are a few things to note about its parameters:
|
|
138
|
-
- ``cost_matrices``: Sequence of $
|
|
144
|
+
- ``cost_matrices``: Sequence of $k (k - 1) / 2$ 2D cost matrices of pairwise matching costs between $k$ partites
|
|
139
145
|
(might not be able to be represented as a 3D ``np.ndarray`` since partites can have different number of vertices)
|
|
140
|
-
ordered as in ``itertools.
|
|
146
|
+
ordered as in ``itertools.combinations(range(k), 2)``. For example, for $k = 4$:
|
|
147
|
+
$[C_{01}, C_{02}, C_{03}, C_{12}, C_{13}, C_{23}]$.
|
|
141
148
|
- ``algo``: This should be one of the six proposed algorithms, namely ``"Am", "Bm", "Cm", "Dm", "Em", "Fm"``.
|
|
142
149
|
$\text{C}_m$ is set to be the default as it usually performs as good as random approaches while having a
|
|
143
150
|
deterministic behavior.
|
|
144
151
|
|
|
145
|
-
|
|
152
|
+
Its return type shares the same structure as that of ``kap.linear_assignment`` but with some small differences:
|
|
146
153
|
- ``matches``: Each element is the list of indices of matched vertices. For example, ``[(0, 0), (1, 1), (2, 0)]``
|
|
147
|
-
indicates that node 0 of partite 0, node 1 of partite 1, and node 0 of partite
|
|
154
|
+
indicates that node 0 of partite 0, node 1 of partite 1, and node 0 of partite 2 are matched together.
|
|
148
155
|
Note that it is **NOT** necessary for a match to contain at least one vertex from each partite (incomplete graph).
|
|
149
156
|
- ``matching_costs``: Each element is the sum of pairwise costs of all edges formed by the matched vertices.
|
|
150
157
|
|
|
@@ -175,24 +182,37 @@ costs = [
|
|
|
175
182
|
matching_result = k_assignment(costs, algo="Em", return_free=True, backend="lap")
|
|
176
183
|
print("Result:", matching_result)
|
|
177
184
|
# Result: AssignmentResult(matches=[[(0, 0), (1, 1), (2, 0)], [(0, 1), (1, 0), (2, 1)], [(0, 2), (1, 2), (2, 2)]], matching_costs=[23, 4, 11], free=[])
|
|
185
|
+
# (with numpy >= 2, the costs print as numpy scalars: [np.int64(23), np.int64(4), np.int64(11)])
|
|
178
186
|
print("Total cost:", sum(matching_result.matching_costs))
|
|
179
187
|
# Total cost: 38
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
These code blocks are extracted from [examples](examples).
|
|
183
191
|
|
|
192
|
+
## Changes in 0.2.3
|
|
193
|
+
|
|
194
|
+
**Upgrade recommended**, and re-run any results computed with $k \geq 4$ partites:
|
|
195
|
+
- Fixed wrong costs for $k \geq 4$: the quotient graph read the cost matrices with the wrong indices,
|
|
196
|
+
so the returned assignment could be suboptimal and the reported costs could even be lower than the true optimum.
|
|
197
|
+
- ``Em``: ``n_trials`` now runs independent trials from the ``Bm`` solution, as in the paper;
|
|
198
|
+
before, it had no effect.
|
|
199
|
+
- ``Fm``: no longer runs to ``max_iter`` when it reaches a plateau; sideways moves must reach a different
|
|
200
|
+
solution and cannot return to the previous one.
|
|
201
|
+
- ``Am``/``Bm``: an internal argument was passed into the wrong parameter of ``linear_assignment`` (no effect on results).
|
|
202
|
+
- Fixed a crash when a fixed matching is empty.
|
|
203
|
+
|
|
184
204
|
## Citation
|
|
185
205
|
|
|
186
206
|
```bibtex
|
|
187
|
-
@software{
|
|
207
|
+
@software{tran_2026_19097964,
|
|
188
208
|
author = {Hoang-Nhat Tran},
|
|
189
|
-
title = {inspiros/kap: v0.2.
|
|
190
|
-
month =
|
|
191
|
-
year =
|
|
209
|
+
title = {inspiros/kap: v0.2.2},
|
|
210
|
+
month = mar,
|
|
211
|
+
year = 2026,
|
|
192
212
|
publisher = {Zenodo},
|
|
193
|
-
version = {v0.2.
|
|
194
|
-
doi = {10.5281/zenodo.
|
|
195
|
-
url = {https://doi.org/10.5281/zenodo.
|
|
213
|
+
version = {v0.2.2},
|
|
214
|
+
doi = {10.5281/zenodo.19097964},
|
|
215
|
+
url = {https://doi.org/10.5281/zenodo.19097964},
|
|
196
216
|
}
|
|
197
217
|
```
|
|
198
218
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
``kap``: $k$-Assignment Problem Solver
|
|
2
2
|
======
|
|
3
|
-
[](https://github.com/inspiros/kap/actions) [](https://pypi.org/project/kap) [](https://pepy.tech/project/kap) [](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [](https://zenodo.org/
|
|
3
|
+
[](https://github.com/inspiros/kap/actions) [](https://pypi.org/project/kap) [](https://pepy.tech/project/kap) [](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [](https://zenodo.org/badge/latestdoi/R_kgDOK5wdCQ)
|
|
4
4
|
|
|
5
5
|
This project implements **Boštjan Gabrovšek**'s Multiple Hungarian Methods for solving the **$k$-Assignment Problem**
|
|
6
6
|
(or **$k$-Partite Graph Matching Problem**), described in [this paper](https://www.mdpi.com/2227-7390/8/11/2050).
|
|
@@ -43,19 +43,22 @@ Further maintenance or performance tuning might be limited, but all contribution
|
|
|
43
43
|
|
|
44
44
|
## Requirements
|
|
45
45
|
|
|
46
|
-
- **Python 3.
|
|
46
|
+
- **Python 3.9+**
|
|
47
47
|
- ``numpy``
|
|
48
|
-
- ``scipy`` or ``lap`` or ``lapjv`` or ``lapsolver`` or ``munkres``
|
|
48
|
+
- ``scipy`` (default backend) or ``lap`` or ``lapjv`` or ``lapsolver`` or ``munkres``
|
|
49
49
|
_(depends on the backend to be used for solving Linear Assignment Problem)_
|
|
50
50
|
|
|
51
51
|
## Installation
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Install with the default ``scipy`` backend:
|
|
54
54
|
|
|
55
55
|
```
|
|
56
|
-
pip install kap
|
|
56
|
+
pip install kap[scipy]
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
``pip install kap`` alone installs only ``numpy``; then install one of the backends above yourself
|
|
60
|
+
(extras ``kap[lap]``, ``kap[lapjv]`` and ``kap[munkres]`` are also available).
|
|
61
|
+
|
|
59
62
|
#### Notes
|
|
60
63
|
|
|
61
64
|
This is currently a pure Python project, but we may add Cython/C++ extension in the future.
|
|
@@ -93,7 +96,7 @@ matching_result = linear_assignment(cost, return_free=True, backend="lap")
|
|
|
93
96
|
print("Result:", matching_result)
|
|
94
97
|
# Result: AssignmentResult(matches=array([[0, 2],
|
|
95
98
|
# [1, 1],
|
|
96
|
-
# [2, 0]]
|
|
99
|
+
# [2, 0]]), matching_costs=array([4, 4, 3]), free=[])
|
|
97
100
|
print("Total cost:", sum(matching_result.matching_costs))
|
|
98
101
|
# Total cost: 11
|
|
99
102
|
```
|
|
@@ -102,16 +105,17 @@ print("Total cost:", sum(matching_result.matching_costs))
|
|
|
102
105
|
|
|
103
106
|
``kap.k_assignment`` is the equivalence for $k$-Assignment Problem.
|
|
104
107
|
There are a few things to note about its parameters:
|
|
105
|
-
- ``cost_matrices``: Sequence of $
|
|
108
|
+
- ``cost_matrices``: Sequence of $k (k - 1) / 2$ 2D cost matrices of pairwise matching costs between $k$ partites
|
|
106
109
|
(might not be able to be represented as a 3D ``np.ndarray`` since partites can have different number of vertices)
|
|
107
|
-
ordered as in ``itertools.
|
|
110
|
+
ordered as in ``itertools.combinations(range(k), 2)``. For example, for $k = 4$:
|
|
111
|
+
$[C_{01}, C_{02}, C_{03}, C_{12}, C_{13}, C_{23}]$.
|
|
108
112
|
- ``algo``: This should be one of the six proposed algorithms, namely ``"Am", "Bm", "Cm", "Dm", "Em", "Fm"``.
|
|
109
113
|
$\text{C}_m$ is set to be the default as it usually performs as good as random approaches while having a
|
|
110
114
|
deterministic behavior.
|
|
111
115
|
|
|
112
|
-
|
|
116
|
+
Its return type shares the same structure as that of ``kap.linear_assignment`` but with some small differences:
|
|
113
117
|
- ``matches``: Each element is the list of indices of matched vertices. For example, ``[(0, 0), (1, 1), (2, 0)]``
|
|
114
|
-
indicates that node 0 of partite 0, node 1 of partite 1, and node 0 of partite
|
|
118
|
+
indicates that node 0 of partite 0, node 1 of partite 1, and node 0 of partite 2 are matched together.
|
|
115
119
|
Note that it is **NOT** necessary for a match to contain at least one vertex from each partite (incomplete graph).
|
|
116
120
|
- ``matching_costs``: Each element is the sum of pairwise costs of all edges formed by the matched vertices.
|
|
117
121
|
|
|
@@ -142,24 +146,37 @@ costs = [
|
|
|
142
146
|
matching_result = k_assignment(costs, algo="Em", return_free=True, backend="lap")
|
|
143
147
|
print("Result:", matching_result)
|
|
144
148
|
# Result: AssignmentResult(matches=[[(0, 0), (1, 1), (2, 0)], [(0, 1), (1, 0), (2, 1)], [(0, 2), (1, 2), (2, 2)]], matching_costs=[23, 4, 11], free=[])
|
|
149
|
+
# (with numpy >= 2, the costs print as numpy scalars: [np.int64(23), np.int64(4), np.int64(11)])
|
|
145
150
|
print("Total cost:", sum(matching_result.matching_costs))
|
|
146
151
|
# Total cost: 38
|
|
147
152
|
```
|
|
148
153
|
|
|
149
154
|
These code blocks are extracted from [examples](examples).
|
|
150
155
|
|
|
156
|
+
## Changes in 0.2.3
|
|
157
|
+
|
|
158
|
+
**Upgrade recommended**, and re-run any results computed with $k \geq 4$ partites:
|
|
159
|
+
- Fixed wrong costs for $k \geq 4$: the quotient graph read the cost matrices with the wrong indices,
|
|
160
|
+
so the returned assignment could be suboptimal and the reported costs could even be lower than the true optimum.
|
|
161
|
+
- ``Em``: ``n_trials`` now runs independent trials from the ``Bm`` solution, as in the paper;
|
|
162
|
+
before, it had no effect.
|
|
163
|
+
- ``Fm``: no longer runs to ``max_iter`` when it reaches a plateau; sideways moves must reach a different
|
|
164
|
+
solution and cannot return to the previous one.
|
|
165
|
+
- ``Am``/``Bm``: an internal argument was passed into the wrong parameter of ``linear_assignment`` (no effect on results).
|
|
166
|
+
- Fixed a crash when a fixed matching is empty.
|
|
167
|
+
|
|
151
168
|
## Citation
|
|
152
169
|
|
|
153
170
|
```bibtex
|
|
154
|
-
@software{
|
|
171
|
+
@software{tran_2026_19097964,
|
|
155
172
|
author = {Hoang-Nhat Tran},
|
|
156
|
-
title = {inspiros/kap: v0.2.
|
|
157
|
-
month =
|
|
158
|
-
year =
|
|
173
|
+
title = {inspiros/kap: v0.2.2},
|
|
174
|
+
month = mar,
|
|
175
|
+
year = 2026,
|
|
159
176
|
publisher = {Zenodo},
|
|
160
|
-
version = {v0.2.
|
|
161
|
-
doi = {10.5281/zenodo.
|
|
162
|
-
url = {https://doi.org/10.5281/zenodo.
|
|
177
|
+
version = {v0.2.2},
|
|
178
|
+
doi = {10.5281/zenodo.19097964},
|
|
179
|
+
url = {https://doi.org/10.5281/zenodo.19097964},
|
|
163
180
|
}
|
|
164
181
|
```
|
|
165
182
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.2.3'
|
|
@@ -25,12 +25,15 @@ def _compute_n_partites(cost_matrices: Sequence[Array2DLike]) -> Optional[int]:
|
|
|
25
25
|
def _check_partites(p1: int, p2: int, M: Optional[Array2DLike] = None):
|
|
26
26
|
if p1 == p2:
|
|
27
27
|
raise ValueError("No diagonal elements in condensed matrix.")
|
|
28
|
+
if M is not None:
|
|
29
|
+
# reshape keeps an empty matching 2D, shape (0, 2)
|
|
30
|
+
M = np.asarray(M, dtype=np.intp).reshape(-1, 2)
|
|
28
31
|
if p1 > p2:
|
|
29
32
|
p1, p2 = p2, p1
|
|
30
33
|
if M is not None:
|
|
31
|
-
M =
|
|
34
|
+
M = M[:, ::-1]
|
|
32
35
|
if M is not None:
|
|
33
|
-
return p1, p2,
|
|
36
|
+
return p1, p2, M
|
|
34
37
|
return p1, p2
|
|
35
38
|
|
|
36
39
|
|
|
@@ -182,7 +185,7 @@ class KPartiteGraph:
|
|
|
182
185
|
cost_ij[:, n_matches_12 + n_free_1:] = cost_i2[:, free_2]
|
|
183
186
|
quotient_cost_matrices.append(cost_ij)
|
|
184
187
|
else:
|
|
185
|
-
quotient_cost_matrices.append(self[
|
|
188
|
+
quotient_cost_matrices.append(self[preq_i, preq_j].copy())
|
|
186
189
|
|
|
187
190
|
quotient_node_indices = KPartiteNamedNodeList()
|
|
188
191
|
for i in range(self.n_partites - 1):
|
|
@@ -376,7 +379,7 @@ def solve_Am(G: KPartiteGraph,
|
|
|
376
379
|
Gq = G.clone()
|
|
377
380
|
for _ in range(G.n_partites - 1):
|
|
378
381
|
# construct quotient graph by merging the two first partites
|
|
379
|
-
Gq = Gq.quotient(0, 1, linear_assignment(Gq[0, 1], maximize,
|
|
382
|
+
Gq = Gq.quotient(0, 1, linear_assignment(Gq[0, 1], maximize,
|
|
380
383
|
backend=backend)[0])
|
|
381
384
|
return Gq
|
|
382
385
|
|
|
@@ -393,7 +396,7 @@ def solve_Bm(G: KPartiteGraph,
|
|
|
393
396
|
|
|
394
397
|
for i, j in itertools.combinations(range(G.n_partites), r=2):
|
|
395
398
|
choices = [(i, j)]
|
|
396
|
-
Gq = G.quotient(i, j, linear_assignment(G[i, j], maximize,
|
|
399
|
+
Gq = G.quotient(i, j, linear_assignment(G[i, j], maximize,
|
|
397
400
|
backend=backend)[0])
|
|
398
401
|
if Gq.n_partites > 1:
|
|
399
402
|
recursive_result = solve_Bm(Gq, maximize, matching_filter,
|
|
@@ -535,12 +538,13 @@ def solve_Em(G: KPartiteGraph,
|
|
|
535
538
|
Gq, choices = solve_Bm(G, maximize,
|
|
536
539
|
return_partites_choices=True,
|
|
537
540
|
backend=backend)
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
+
init_Gq, init_first_partites_pair = Gq, choices[0]
|
|
542
|
+
best_Gq = init_Gq
|
|
543
|
+
best_score = init_Gq.vertices_cost()
|
|
541
544
|
|
|
545
|
+
# independent trials, each a randomised descent from the Bm solution
|
|
542
546
|
for ti in range(n_trials):
|
|
543
|
-
Gq, score = _Em_loop(G,
|
|
547
|
+
Gq, score = _Em_loop(G, init_Gq, init_first_partites_pair, maximize, max_iter,
|
|
544
548
|
backend=backend, random_state=random_state)
|
|
545
549
|
if (score < best_score and not maximize) or (score > best_score and maximize):
|
|
546
550
|
best_Gq = Gq
|
|
@@ -568,8 +572,9 @@ def solve_Fm(G: KPartiteGraph,
|
|
|
568
572
|
best_score = Gq.vertices_cost()
|
|
569
573
|
best_edges_list = [Gq.vertices()]
|
|
570
574
|
best_first_partites_pair_list = [choices[0]]
|
|
575
|
+
previous_edges = None
|
|
571
576
|
|
|
572
|
-
# steepest descent
|
|
577
|
+
# steepest descent with sideways moves; the current and previous solutions are tabu
|
|
573
578
|
for iteration in range(max_iter):
|
|
574
579
|
improvement_achieved = False
|
|
575
580
|
|
|
@@ -577,6 +582,8 @@ def solve_Fm(G: KPartiteGraph,
|
|
|
577
582
|
best_Gq = best_Gq_list[rand_ind]
|
|
578
583
|
best_edges = best_edges_list[rand_ind]
|
|
579
584
|
best_first_partites_pair = best_first_partites_pair_list[rand_ind]
|
|
585
|
+
tabu = (best_edges, previous_edges)
|
|
586
|
+
previous_edges = best_edges
|
|
580
587
|
best_Gq_list.clear()
|
|
581
588
|
best_edges_list.clear()
|
|
582
589
|
best_first_partites_pair_list.clear()
|
|
@@ -590,18 +597,23 @@ def solve_Fm(G: KPartiteGraph,
|
|
|
590
597
|
return_partites_choices=True,
|
|
591
598
|
backend=backend)
|
|
592
599
|
score = Gq.vertices_cost()
|
|
600
|
+
edges = Gq.vertices()
|
|
593
601
|
if score == best_score:
|
|
602
|
+
if edges in tabu or edges in best_edges_list:
|
|
603
|
+
continue
|
|
594
604
|
best_Gq_list.append(Gq)
|
|
595
|
-
best_edges_list.append(
|
|
605
|
+
best_edges_list.append(edges)
|
|
596
606
|
best_first_partites_pair_list.append(choices[0])
|
|
597
607
|
improvement_achieved = True
|
|
598
608
|
elif (score < best_score and not maximize) or (score > best_score and maximize):
|
|
599
609
|
best_Gq_list = [Gq]
|
|
600
610
|
best_score = score
|
|
601
|
-
best_edges_list = [
|
|
611
|
+
best_edges_list = [edges]
|
|
602
612
|
best_first_partites_pair_list = [choices[0]]
|
|
603
613
|
improvement_achieved = True
|
|
604
614
|
if not improvement_achieved:
|
|
615
|
+
# keep the current solution
|
|
616
|
+
best_Gq_list = [best_Gq]
|
|
605
617
|
break
|
|
606
618
|
if len(best_Gq_list):
|
|
607
619
|
rand_ind = random_state.randint(0, len(best_Gq_list))
|
|
@@ -1,39 +1,42 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kap
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Gabrovšek's Multiple Hungarian method for solving k-Assignment Problem.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Author-email: hnhat.tran@gmail.com
|
|
8
|
-
License: MIT
|
|
5
|
+
Author-email: "Hoang-Nhat Tran (inspiros)" <hnhat.tran@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
9
7
|
Project-URL: Source, https://github.com/inspiros/kap
|
|
8
|
+
Project-URL: Download, https://pypi.org/project/kap
|
|
10
9
|
Keywords: k-assignment,k-partite-matching,linear-assignment,bi-partite-matching,hungarian
|
|
11
10
|
Classifier: Development Status :: 3 - Alpha
|
|
12
11
|
Classifier: Intended Audience :: Developers
|
|
13
12
|
Classifier: Intended Audience :: Education
|
|
14
13
|
Classifier: Intended Audience :: Science/Research
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
-
Classifier: Programming Language :: Python
|
|
17
14
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
26
15
|
Classifier: Operating System :: OS Independent
|
|
27
16
|
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
28
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.9
|
|
29
18
|
Description-Content-Type: text/markdown
|
|
30
19
|
License-File: LICENSE.txt
|
|
31
20
|
Requires-Dist: numpy
|
|
21
|
+
Provides-Extra: scipy
|
|
22
|
+
Requires-Dist: scipy; extra == "scipy"
|
|
23
|
+
Provides-Extra: lap
|
|
24
|
+
Requires-Dist: lap; extra == "lap"
|
|
25
|
+
Provides-Extra: lapjv
|
|
26
|
+
Requires-Dist: lapjv; extra == "lapjv"
|
|
27
|
+
Provides-Extra: munkres
|
|
28
|
+
Requires-Dist: munkres; extra == "munkres"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Requires-Dist: scipy; extra == "test"
|
|
32
|
+
Requires-Dist: lap; extra == "test"
|
|
33
|
+
Requires-Dist: lapjv; extra == "test"
|
|
34
|
+
Requires-Dist: munkres; extra == "test"
|
|
32
35
|
Dynamic: license-file
|
|
33
36
|
|
|
34
37
|
``kap``: $k$-Assignment Problem Solver
|
|
35
38
|
======
|
|
36
|
-
[](https://github.com/inspiros/kap/actions) [](https://pypi.org/project/kap) [](https://pepy.tech/project/kap) [](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [](https://zenodo.org/
|
|
39
|
+
[](https://github.com/inspiros/kap/actions) [](https://pypi.org/project/kap) [](https://pepy.tech/project/kap) [](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [](https://zenodo.org/badge/latestdoi/R_kgDOK5wdCQ)
|
|
37
40
|
|
|
38
41
|
This project implements **Boštjan Gabrovšek**'s Multiple Hungarian Methods for solving the **$k$-Assignment Problem**
|
|
39
42
|
(or **$k$-Partite Graph Matching Problem**), described in [this paper](https://www.mdpi.com/2227-7390/8/11/2050).
|
|
@@ -76,19 +79,22 @@ Further maintenance or performance tuning might be limited, but all contribution
|
|
|
76
79
|
|
|
77
80
|
## Requirements
|
|
78
81
|
|
|
79
|
-
- **Python 3.
|
|
82
|
+
- **Python 3.9+**
|
|
80
83
|
- ``numpy``
|
|
81
|
-
- ``scipy`` or ``lap`` or ``lapjv`` or ``lapsolver`` or ``munkres``
|
|
84
|
+
- ``scipy`` (default backend) or ``lap`` or ``lapjv`` or ``lapsolver`` or ``munkres``
|
|
82
85
|
_(depends on the backend to be used for solving Linear Assignment Problem)_
|
|
83
86
|
|
|
84
87
|
## Installation
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
Install with the default ``scipy`` backend:
|
|
87
90
|
|
|
88
91
|
```
|
|
89
|
-
pip install kap
|
|
92
|
+
pip install kap[scipy]
|
|
90
93
|
```
|
|
91
94
|
|
|
95
|
+
``pip install kap`` alone installs only ``numpy``; then install one of the backends above yourself
|
|
96
|
+
(extras ``kap[lap]``, ``kap[lapjv]`` and ``kap[munkres]`` are also available).
|
|
97
|
+
|
|
92
98
|
#### Notes
|
|
93
99
|
|
|
94
100
|
This is currently a pure Python project, but we may add Cython/C++ extension in the future.
|
|
@@ -126,7 +132,7 @@ matching_result = linear_assignment(cost, return_free=True, backend="lap")
|
|
|
126
132
|
print("Result:", matching_result)
|
|
127
133
|
# Result: AssignmentResult(matches=array([[0, 2],
|
|
128
134
|
# [1, 1],
|
|
129
|
-
# [2, 0]]
|
|
135
|
+
# [2, 0]]), matching_costs=array([4, 4, 3]), free=[])
|
|
130
136
|
print("Total cost:", sum(matching_result.matching_costs))
|
|
131
137
|
# Total cost: 11
|
|
132
138
|
```
|
|
@@ -135,16 +141,17 @@ print("Total cost:", sum(matching_result.matching_costs))
|
|
|
135
141
|
|
|
136
142
|
``kap.k_assignment`` is the equivalence for $k$-Assignment Problem.
|
|
137
143
|
There are a few things to note about its parameters:
|
|
138
|
-
- ``cost_matrices``: Sequence of $
|
|
144
|
+
- ``cost_matrices``: Sequence of $k (k - 1) / 2$ 2D cost matrices of pairwise matching costs between $k$ partites
|
|
139
145
|
(might not be able to be represented as a 3D ``np.ndarray`` since partites can have different number of vertices)
|
|
140
|
-
ordered as in ``itertools.
|
|
146
|
+
ordered as in ``itertools.combinations(range(k), 2)``. For example, for $k = 4$:
|
|
147
|
+
$[C_{01}, C_{02}, C_{03}, C_{12}, C_{13}, C_{23}]$.
|
|
141
148
|
- ``algo``: This should be one of the six proposed algorithms, namely ``"Am", "Bm", "Cm", "Dm", "Em", "Fm"``.
|
|
142
149
|
$\text{C}_m$ is set to be the default as it usually performs as good as random approaches while having a
|
|
143
150
|
deterministic behavior.
|
|
144
151
|
|
|
145
|
-
|
|
152
|
+
Its return type shares the same structure as that of ``kap.linear_assignment`` but with some small differences:
|
|
146
153
|
- ``matches``: Each element is the list of indices of matched vertices. For example, ``[(0, 0), (1, 1), (2, 0)]``
|
|
147
|
-
indicates that node 0 of partite 0, node 1 of partite 1, and node 0 of partite
|
|
154
|
+
indicates that node 0 of partite 0, node 1 of partite 1, and node 0 of partite 2 are matched together.
|
|
148
155
|
Note that it is **NOT** necessary for a match to contain at least one vertex from each partite (incomplete graph).
|
|
149
156
|
- ``matching_costs``: Each element is the sum of pairwise costs of all edges formed by the matched vertices.
|
|
150
157
|
|
|
@@ -175,24 +182,37 @@ costs = [
|
|
|
175
182
|
matching_result = k_assignment(costs, algo="Em", return_free=True, backend="lap")
|
|
176
183
|
print("Result:", matching_result)
|
|
177
184
|
# Result: AssignmentResult(matches=[[(0, 0), (1, 1), (2, 0)], [(0, 1), (1, 0), (2, 1)], [(0, 2), (1, 2), (2, 2)]], matching_costs=[23, 4, 11], free=[])
|
|
185
|
+
# (with numpy >= 2, the costs print as numpy scalars: [np.int64(23), np.int64(4), np.int64(11)])
|
|
178
186
|
print("Total cost:", sum(matching_result.matching_costs))
|
|
179
187
|
# Total cost: 38
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
These code blocks are extracted from [examples](examples).
|
|
183
191
|
|
|
192
|
+
## Changes in 0.2.3
|
|
193
|
+
|
|
194
|
+
**Upgrade recommended**, and re-run any results computed with $k \geq 4$ partites:
|
|
195
|
+
- Fixed wrong costs for $k \geq 4$: the quotient graph read the cost matrices with the wrong indices,
|
|
196
|
+
so the returned assignment could be suboptimal and the reported costs could even be lower than the true optimum.
|
|
197
|
+
- ``Em``: ``n_trials`` now runs independent trials from the ``Bm`` solution, as in the paper;
|
|
198
|
+
before, it had no effect.
|
|
199
|
+
- ``Fm``: no longer runs to ``max_iter`` when it reaches a plateau; sideways moves must reach a different
|
|
200
|
+
solution and cannot return to the previous one.
|
|
201
|
+
- ``Am``/``Bm``: an internal argument was passed into the wrong parameter of ``linear_assignment`` (no effect on results).
|
|
202
|
+
- Fixed a crash when a fixed matching is empty.
|
|
203
|
+
|
|
184
204
|
## Citation
|
|
185
205
|
|
|
186
206
|
```bibtex
|
|
187
|
-
@software{
|
|
207
|
+
@software{tran_2026_19097964,
|
|
188
208
|
author = {Hoang-Nhat Tran},
|
|
189
|
-
title = {inspiros/kap: v0.2.
|
|
190
|
-
month =
|
|
191
|
-
year =
|
|
209
|
+
title = {inspiros/kap: v0.2.2},
|
|
210
|
+
month = mar,
|
|
211
|
+
year = 2026,
|
|
192
212
|
publisher = {Zenodo},
|
|
193
|
-
version = {v0.2.
|
|
194
|
-
doi = {10.5281/zenodo.
|
|
195
|
-
url = {https://doi.org/10.5281/zenodo.
|
|
213
|
+
version = {v0.2.2},
|
|
214
|
+
doi = {10.5281/zenodo.19097964},
|
|
215
|
+
url = {https://doi.org/10.5281/zenodo.19097964},
|
|
196
216
|
}
|
|
197
217
|
```
|
|
198
218
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
LICENSE.txt
|
|
2
2
|
README.md
|
|
3
|
-
|
|
4
|
-
setup.py
|
|
3
|
+
pyproject.toml
|
|
5
4
|
kap/__init__.py
|
|
6
5
|
kap/_types.py
|
|
7
6
|
kap/_version.py
|
|
@@ -9,6 +8,7 @@ kap/assignment.py
|
|
|
9
8
|
kap.egg-info/PKG-INFO
|
|
10
9
|
kap.egg-info/SOURCES.txt
|
|
11
10
|
kap.egg-info/dependency_links.txt
|
|
12
|
-
kap.egg-info/not-zip-safe
|
|
13
11
|
kap.egg-info/requires.txt
|
|
14
|
-
kap.egg-info/top_level.txt
|
|
12
|
+
kap.egg-info/top_level.txt
|
|
13
|
+
tests/test_k_assignment.py
|
|
14
|
+
tests/test_linear_assignment.py
|
kap-0.2.3/pyproject.toml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kap"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Gabrovšek's Multiple Hungarian method for solving k-Assignment Problem."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.txt"]
|
|
12
|
+
authors = [{ name = "Hoang-Nhat Tran (inspiros)", email = "hnhat.tran@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["numpy"]
|
|
15
|
+
keywords = ["k-assignment", "k-partite-matching", "linear-assignment", "bi-partite-matching", "hungarian"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Education",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
scipy = ["scipy"]
|
|
28
|
+
lap = ["lap"]
|
|
29
|
+
lapjv = ["lapjv"]
|
|
30
|
+
munkres = ["munkres"]
|
|
31
|
+
test = ["pytest", "scipy", "lap", "lapjv", "munkres"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Source = "https://github.com/inspiros/kap"
|
|
35
|
+
Download = "https://pypi.org/project/kap"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
include = ["kap*"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.dynamic]
|
|
41
|
+
version = { attr = "kap._version.__version__" }
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
kap-0.2.3/setup.cfg
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Exercise clique costs, coverage, heuristics, and public input validation."""
|
|
2
|
+
|
|
3
|
+
from itertools import product
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import kap
|
|
9
|
+
|
|
10
|
+
from conftest import ALGORITHMS, DTYPES, assert_valid_assignment, brute_force_optimum, clique_cost
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
pytestmark = pytest.mark.usefixtures("scipy_assignment")
|
|
14
|
+
|
|
15
|
+
# A short iteration budget keeps Fm's exploration of tied integer solutions
|
|
16
|
+
# bounded. These are correctness checks, not a heuristic quality benchmark.
|
|
17
|
+
SOLVER_OPTIONS = dict(backend="scipy", return_free=True, random_state=42, max_iter=5)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def random_cases():
|
|
21
|
+
for algo, k, equal, dtype in product(
|
|
22
|
+
ALGORITHMS, (3, 4, 5), (True, False), (np.float64, np.int64)
|
|
23
|
+
):
|
|
24
|
+
yield pytest.param(
|
|
25
|
+
algo, k, equal, dtype,
|
|
26
|
+
id=f"{algo}-k{k}-{'equal' if equal else 'unequal'}-{np.dtype(dtype).name}",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_paper_example(paper_costs):
|
|
31
|
+
# Explicit edges also sanity-check the independent oracle against the README.
|
|
32
|
+
paper_matches = [
|
|
33
|
+
[(0, 0), (1, 1), (2, 0)],
|
|
34
|
+
[(0, 1), (1, 0), (2, 1)],
|
|
35
|
+
[(0, 2), (1, 2), (2, 2)],
|
|
36
|
+
]
|
|
37
|
+
assert [clique_cost(paper_costs, 3, [m]) for m in paper_matches] == [23, 4, 11]
|
|
38
|
+
result = kap.k_assignment(paper_costs, algo="Em", **SOLVER_OPTIONS)
|
|
39
|
+
assert_valid_assignment(paper_costs, (3, 3, 3), result)
|
|
40
|
+
assert sum(result.matching_costs) == 38
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("algo", ALGORITHMS)
|
|
44
|
+
def test_paper_example_valid_for_every_algorithm(paper_costs, algo):
|
|
45
|
+
result = kap.k_assignment(paper_costs, algo=algo, **SOLVER_OPTIONS)
|
|
46
|
+
assert_valid_assignment(paper_costs, (3, 3, 3), result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("algo,k,equal,dtype", tuple(random_cases()))
|
|
50
|
+
def test_clique_costs_and_node_coverage(instance_factory, algo, k, equal, dtype):
|
|
51
|
+
sizes = (3,) * k if equal else tuple(2 + p % 3 for p in range(k))
|
|
52
|
+
costs = instance_factory(k, sizes=sizes, dtype=dtype, seed=1729 + k)
|
|
53
|
+
result = kap.k_assignment(costs, algo=algo, **SOLVER_OPTIONS)
|
|
54
|
+
# Checking each clique against original matrices catches quotient copies
|
|
55
|
+
# that incorrectly use renumbered partites, especially for k >= 4.
|
|
56
|
+
assert_valid_assignment(costs, sizes, result)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize("algo", ("Cm", "Em", "Fm"))
|
|
60
|
+
def test_unequal_sizes_with_empty_fixed_pair(instance_factory, algo):
|
|
61
|
+
# Regression: a partite pair with no fixed edges used to crash quotient().
|
|
62
|
+
sizes = (2, 3, 4, 2)
|
|
63
|
+
costs = instance_factory(4, sizes=sizes, dtype=np.int64, seed=104)
|
|
64
|
+
result = kap.k_assignment(costs, algo=algo, **SOLVER_OPTIONS)
|
|
65
|
+
assert_valid_assignment(costs, sizes, result)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture(
|
|
69
|
+
params=((3, 3, 3), (3, 4, 7), (4, 3, 11), (5, 2, 19)),
|
|
70
|
+
ids=("k3-n3-seed3", "k3-n4-seed7", "k4-n3-seed11", "k5-n2-seed19"),
|
|
71
|
+
scope="module",
|
|
72
|
+
)
|
|
73
|
+
def tiny_instance(request):
|
|
74
|
+
# Compute the factorial oracle once per instance, not once per algorithm.
|
|
75
|
+
from conftest import random_instance
|
|
76
|
+
|
|
77
|
+
k, n, seed = request.param
|
|
78
|
+
costs = random_instance(k, n=n, seed=seed)
|
|
79
|
+
return costs, k, n, brute_force_optimum(costs, k)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("algo", ALGORITHMS)
|
|
83
|
+
def test_against_brute_force(tiny_instance, algo):
|
|
84
|
+
costs, k, n, optimum = tiny_instance
|
|
85
|
+
result = kap.k_assignment(costs, algo=algo, **SOLVER_OPTIONS)
|
|
86
|
+
assert_valid_assignment(costs, (n,) * k, result)
|
|
87
|
+
true_cost = clique_cost(costs, k, result.matches)
|
|
88
|
+
assert true_cost >= optimum - 1e-8
|
|
89
|
+
# Empirically verified on these four seeds; no general optimality claim.
|
|
90
|
+
if algo in ("Bm", "Cm", "Em", "Fm"):
|
|
91
|
+
assert np.isclose(true_cost, optimum)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("algo,k,equal,dtype", tuple(random_cases()))
|
|
95
|
+
def test_maximize(instance_factory, algo, k, equal, dtype):
|
|
96
|
+
sizes = (3,) * k if equal else tuple(2 + p % 3 for p in range(k))
|
|
97
|
+
costs = instance_factory(k, sizes=sizes, dtype=dtype, seed=789 + k)
|
|
98
|
+
minimum = kap.k_assignment(costs, algo=algo, **SOLVER_OPTIONS)
|
|
99
|
+
maximum = kap.k_assignment(costs, algo=algo, maximize=True, **SOLVER_OPTIONS)
|
|
100
|
+
assert_valid_assignment(costs, sizes, minimum)
|
|
101
|
+
assert_valid_assignment(costs, sizes, maximum)
|
|
102
|
+
assert clique_cost(costs, k, maximum.matches) >= clique_cost(costs, k, minimum.matches) - 1e-8
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize("algo", ALGORITHMS)
|
|
106
|
+
@pytest.mark.parametrize("shape", ((4, 4), (2, 5), (5, 2)))
|
|
107
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
108
|
+
@pytest.mark.parametrize("maximize", (False, True), ids=("min", "max"))
|
|
109
|
+
def test_two_partites_equal_lap_optimum(
|
|
110
|
+
instance_factory, scipy_assignment, algo, shape, dtype, maximize
|
|
111
|
+
):
|
|
112
|
+
costs = instance_factory(2, sizes=shape, dtype=dtype, seed=675)
|
|
113
|
+
rows, cols = scipy_assignment(costs[0], maximize=maximize)
|
|
114
|
+
result = kap.k_assignment(costs, algo=algo, maximize=maximize, **SOLVER_OPTIONS)
|
|
115
|
+
assert_valid_assignment(costs, shape, result)
|
|
116
|
+
assert np.isclose(clique_cost(costs, 2, result.matches), costs[0][rows, cols].sum())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("algo", ("Em", "Fm"))
|
|
120
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
121
|
+
@pytest.mark.parametrize("maximize", (False, True), ids=("min", "max"))
|
|
122
|
+
def test_random_state_reproducibility(instance_factory, algo, dtype, maximize):
|
|
123
|
+
costs = instance_factory(4, n=3, dtype=dtype, seed=53)
|
|
124
|
+
options = dict(SOLVER_OPTIONS, n_trials=3, maximize=maximize)
|
|
125
|
+
first = kap.k_assignment(costs, algo=algo, **options)
|
|
126
|
+
second = kap.k_assignment(costs, algo=algo, **options)
|
|
127
|
+
assert_valid_assignment(costs, (3,) * 4, first)
|
|
128
|
+
assert_valid_assignment(costs, (3,) * 4, second)
|
|
129
|
+
assert first.matches == second.matches
|
|
130
|
+
np.testing.assert_array_equal(first.matching_costs, second.matching_costs)
|
|
131
|
+
assert first.free == second.free
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.parametrize("algo", ("Am", "Bm", "Dm"))
|
|
135
|
+
@pytest.mark.parametrize("backend", ("scipy", "lap", "munkres"))
|
|
136
|
+
@pytest.mark.parametrize("seed", (23, 91))
|
|
137
|
+
def test_backend_parity(instance_factory, algo, backend, seed):
|
|
138
|
+
pytest.importorskip(backend)
|
|
139
|
+
costs = instance_factory(4, n=3, seed=seed)
|
|
140
|
+
reference = kap.k_assignment(costs, algo=algo, **SOLVER_OPTIONS)
|
|
141
|
+
result = kap.k_assignment(costs, algo=algo, **dict(SOLVER_OPTIONS, backend=backend))
|
|
142
|
+
assert_valid_assignment(costs, (3,) * 4, result)
|
|
143
|
+
assert np.isclose(sum(result.matching_costs), sum(reference.matching_costs))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.parametrize(
|
|
147
|
+
"costs",
|
|
148
|
+
(
|
|
149
|
+
pytest.param([], id="empty"),
|
|
150
|
+
pytest.param([np.ones((2, 2))] * 2, id="invalid-matrix-count"),
|
|
151
|
+
pytest.param([np.ones(3)], id="one-dimensional"),
|
|
152
|
+
pytest.param([np.ones((2, 2, 2))], id="three-dimensional"),
|
|
153
|
+
pytest.param([np.array(1)], id="scalar"),
|
|
154
|
+
pytest.param(
|
|
155
|
+
[np.ones((2, 3)), np.ones((4, 2)), np.ones((3, 2))],
|
|
156
|
+
id="inconsistent-row-size",
|
|
157
|
+
),
|
|
158
|
+
pytest.param(
|
|
159
|
+
[np.ones((2, 3)), np.ones((2, 4)), np.ones((3, 5))],
|
|
160
|
+
id="inconsistent-column-size",
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
def test_invalid_cost_matrices_raise_value_error(costs):
|
|
165
|
+
with pytest.raises(ValueError):
|
|
166
|
+
kap.k_assignment(costs)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("option", ({"algo": "unknown"}, {"backend": "unknown"}))
|
|
170
|
+
def test_unknown_solver_option_raises_value_error(paper_costs, option):
|
|
171
|
+
with pytest.raises(ValueError, match=next(iter(option))):
|
|
172
|
+
kap.k_assignment(paper_costs, **option)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.parametrize("algo", ALGORITHMS)
|
|
176
|
+
def test_free_is_none_by_default(paper_costs, algo):
|
|
177
|
+
assert kap.k_assignment(paper_costs, algo=algo, random_state=42, max_iter=5).free is None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Regression tests for 0.2.3 fixes.
|
|
181
|
+
|
|
182
|
+
from conftest import random_instance # noqa: E402
|
|
183
|
+
import kap.assignment as _assignment # noqa: E402
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_lap_calls_never_request_free(monkeypatch, paper_costs):
|
|
187
|
+
"""matching_filter must not land in linear_assignment's return_free slot."""
|
|
188
|
+
original = _assignment.linear_assignment
|
|
189
|
+
|
|
190
|
+
def checked(cost_matrix, maximize=False, return_free=False, backend="scipy"):
|
|
191
|
+
assert return_free is False
|
|
192
|
+
return original(cost_matrix, maximize, return_free, backend)
|
|
193
|
+
|
|
194
|
+
monkeypatch.setattr(_assignment, "linear_assignment", checked)
|
|
195
|
+
G = _assignment.KPartiteGraph([np.asarray(c) for c in paper_costs], 3)
|
|
196
|
+
_assignment.solve_Am(G, matching_filter=object(), backend="scipy")
|
|
197
|
+
_assignment.solve_Bm(G, matching_filter=object(), backend="scipy")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_em_trials_are_independent(monkeypatch):
|
|
201
|
+
"""Every Em trial starts from the Bm solution, not from the previous trial."""
|
|
202
|
+
costs = random_instance(4, seed=0, n=6) # Em improves on Bm here
|
|
203
|
+
G = _assignment.KPartiteGraph(costs, 4)
|
|
204
|
+
bm = _assignment.solve_Bm(G, backend="scipy").vertices()
|
|
205
|
+
starts = []
|
|
206
|
+
original = _assignment._Em_loop
|
|
207
|
+
|
|
208
|
+
def recording(G_, G_init, *args, **kwargs):
|
|
209
|
+
starts.append(G_init.vertices())
|
|
210
|
+
return original(G_, G_init, *args, **kwargs)
|
|
211
|
+
|
|
212
|
+
monkeypatch.setattr(_assignment, "_Em_loop", recording)
|
|
213
|
+
_assignment.solve_Em(G, n_trials=4, backend="scipy", random_state=0)
|
|
214
|
+
assert starts == [bm] * 4
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_em_more_trials_never_worse():
|
|
218
|
+
costs = random_instance(4, seed=5, n=6)
|
|
219
|
+
one = kap.k_assignment(costs, algo="Em", n_trials=1, random_state=1)
|
|
220
|
+
many = kap.k_assignment(costs, algo="Em", n_trials=10, random_state=1)
|
|
221
|
+
assert sum(many.matching_costs) <= sum(one.matching_costs)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_fm_stops_on_a_plateau(monkeypatch):
|
|
225
|
+
"""Fm must stop when no different solution is at least as good, not run to max_iter."""
|
|
226
|
+
costs = random_instance(4, seed=0, n=5, dtype=np.int64)
|
|
227
|
+
G = _assignment.KPartiteGraph(costs, 4)
|
|
228
|
+
calls = []
|
|
229
|
+
original = _assignment.solve_Bm
|
|
230
|
+
|
|
231
|
+
def counting(*args, **kwargs):
|
|
232
|
+
calls.append(1)
|
|
233
|
+
return original(*args, **kwargs)
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr(_assignment, "solve_Bm", counting)
|
|
236
|
+
max_iter = 1000
|
|
237
|
+
result = _assignment.solve_Fm(G, max_iter=max_iter, backend="scipy", random_state=0)
|
|
238
|
+
n_pairs = 6
|
|
239
|
+
assert len(calls) < 50 * n_pairs # was ~max_iter * (n_pairs - 1) before the fix
|
|
240
|
+
assert result.vertices_cost() <= original(G, backend="scipy").vertices_cost()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Compare each available LAP wrapper with SciPy's exact solver."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import kap
|
|
7
|
+
|
|
8
|
+
from conftest import DTYPES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def assert_lap_result(cost, result, scipy_assignment, maximize):
|
|
12
|
+
rows, columns = scipy_assignment(cost, maximize=maximize)
|
|
13
|
+
assert np.isclose(sum(result.matching_costs), cost[rows, columns].sum())
|
|
14
|
+
matches = np.asarray(result.matches)
|
|
15
|
+
assert matches.shape == (min(cost.shape), 2)
|
|
16
|
+
assert np.issubdtype(matches.dtype, np.integer)
|
|
17
|
+
assert len(set(matches[:, 0])) == len(matches)
|
|
18
|
+
assert len(set(matches[:, 1])) == len(matches)
|
|
19
|
+
assert np.all((0 <= matches[:, 0]) & (matches[:, 0] < cost.shape[0]))
|
|
20
|
+
assert np.all((0 <= matches[:, 1]) & (matches[:, 1] < cost.shape[1]))
|
|
21
|
+
np.testing.assert_array_equal(
|
|
22
|
+
result.matching_costs, cost[matches[:, 0], matches[:, 1]]
|
|
23
|
+
)
|
|
24
|
+
expected_free = {
|
|
25
|
+
(0, i) for i in set(range(cost.shape[0])) - set(matches[:, 0])
|
|
26
|
+
} | {(1, j) for j in set(range(cost.shape[1])) - set(matches[:, 1])}
|
|
27
|
+
assert set(result.free) == expected_free
|
|
28
|
+
assert len(result.free) == len(expected_free)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("n", (1, 5, 8))
|
|
32
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
33
|
+
@pytest.mark.parametrize("maximize", (False, True), ids=("min", "max"))
|
|
34
|
+
def test_square_optimum(backend, scipy_assignment, instance_factory, n, dtype, maximize):
|
|
35
|
+
cost = instance_factory(2, n=n, dtype=dtype, seed=210 + n)[0]
|
|
36
|
+
result = kap.linear_assignment(cost, backend=backend, maximize=maximize, return_free=True)
|
|
37
|
+
assert_lap_result(cost, result, scipy_assignment, maximize)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("shape", ((3, 7), (7, 3)), ids=("wide", "tall"))
|
|
41
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
42
|
+
@pytest.mark.parametrize("maximize", (False, True), ids=("min", "max"))
|
|
43
|
+
def test_rectangular_optimum(
|
|
44
|
+
rectangular_backend, scipy_assignment, instance_factory, shape, dtype, maximize
|
|
45
|
+
):
|
|
46
|
+
cost = instance_factory(2, sizes=shape, dtype=dtype, seed=321)[0]
|
|
47
|
+
result = kap.linear_assignment(
|
|
48
|
+
cost, backend=rectangular_backend, maximize=maximize, return_free=True
|
|
49
|
+
)
|
|
50
|
+
assert_lap_result(cost, result, scipy_assignment, maximize)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("shape", ((3, 7), (7, 3)), ids=("wide", "tall"))
|
|
54
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
55
|
+
def test_lapjv_rejects_rectangular_input(instance_factory, shape, dtype):
|
|
56
|
+
pytest.importorskip("lapjv")
|
|
57
|
+
cost = instance_factory(2, sizes=shape, dtype=dtype, seed=321)[0]
|
|
58
|
+
with pytest.raises(ValueError, match="square"):
|
|
59
|
+
kap.linear_assignment(cost, backend="lapjv")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_free_is_none_by_default(backend, instance_factory):
|
|
63
|
+
cost = instance_factory(2, n=3, seed=75)[0]
|
|
64
|
+
assert kap.linear_assignment(cost, backend=backend).free is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_unknown_backend_raises_value_error():
|
|
68
|
+
with pytest.raises(ValueError, match="backend"):
|
|
69
|
+
kap.linear_assignment(np.ones((2, 2)), backend="unknown")
|
kap-0.2.2/kap/_version.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.2.2'
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
numpy
|
kap-0.2.2/setup.cfg
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
[metadata]
|
|
2
|
-
name = kap
|
|
3
|
-
description = Gabrovšek's Multiple Hungarian method for solving k-Assignment Problem.
|
|
4
|
-
long_description = file: README.md
|
|
5
|
-
long_description_content_type = text/markdown
|
|
6
|
-
license = MIT
|
|
7
|
-
license_files = LICENSE.txt
|
|
8
|
-
author = Hoang-Nhat Tran (inspiros)
|
|
9
|
-
author_email = hnhat.tran@gmail.com
|
|
10
|
-
url = https://github.com/inspiros/kap
|
|
11
|
-
download_urls = https://pypi.org/project/kap
|
|
12
|
-
project_urls =
|
|
13
|
-
Source = https://github.com/inspiros/kap
|
|
14
|
-
classifiers =
|
|
15
|
-
Development Status :: 3 - Alpha
|
|
16
|
-
Intended Audience :: Developers
|
|
17
|
-
Intended Audience :: Education
|
|
18
|
-
Intended Audience :: Science/Research
|
|
19
|
-
License :: OSI Approved :: MIT License
|
|
20
|
-
Programming Language :: Python
|
|
21
|
-
Programming Language :: Python :: 3
|
|
22
|
-
Programming Language :: Python :: 3.7
|
|
23
|
-
Programming Language :: Python :: 3.8
|
|
24
|
-
Programming Language :: Python :: 3.9
|
|
25
|
-
Programming Language :: Python :: 3.10
|
|
26
|
-
Programming Language :: Python :: 3.11
|
|
27
|
-
Programming Language :: Python :: 3.12
|
|
28
|
-
Programming Language :: Python :: 3.13
|
|
29
|
-
Programming Language :: Python :: 3.14
|
|
30
|
-
Operating System :: OS Independent
|
|
31
|
-
Topic :: Scientific/Engineering :: Mathematics
|
|
32
|
-
keywords = k-assignment, k-partite-matching, linear-assignment, bi-partite-matching, hungarian
|
|
33
|
-
|
|
34
|
-
[options]
|
|
35
|
-
zip_safe = False
|
|
36
|
-
include_package_data = True
|
|
37
|
-
packages = find:
|
|
38
|
-
python_requires = >=3.7
|
|
39
|
-
setup_requires = numpy
|
|
40
|
-
install_requires = numpy
|
|
41
|
-
|
|
42
|
-
[options.packages.find]
|
|
43
|
-
exclude =
|
|
44
|
-
examples*
|
|
45
|
-
tools*
|
|
46
|
-
docs*
|
|
47
|
-
resources*
|
|
48
|
-
tests*
|
|
49
|
-
|
|
50
|
-
[egg_info]
|
|
51
|
-
tag_build =
|
|
52
|
-
tag_date = 0
|
|
53
|
-
|
kap-0.2.2/setup.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
from setuptools import setup
|
|
4
|
-
|
|
5
|
-
PACKAGE_ROOT = 'kap'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def get_version(version_file='_version.py'):
|
|
9
|
-
import importlib.util
|
|
10
|
-
version_file_path = os.path.abspath(os.path.join(PACKAGE_ROOT, version_file))
|
|
11
|
-
spec = importlib.util.spec_from_file_location('_version', version_file_path)
|
|
12
|
-
version_module = importlib.util.module_from_spec(spec)
|
|
13
|
-
spec.loader.exec_module(version_module)
|
|
14
|
-
return str(version_module.__version__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def setup_package():
|
|
18
|
-
setup(version=get_version())
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if __name__ == '__main__':
|
|
22
|
-
setup_package()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|