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.
@@ -1,39 +1,42 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kap
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Gabrovšek's Multiple Hungarian method for solving k-Assignment Problem.
5
- Home-page: https://github.com/inspiros/kap
6
- Author: Hoang-Nhat Tran (inspiros)
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.7
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
- [![Build wheels](https://github.com/inspiros/kap/actions/workflows/build_wheels.yml/badge.svg)](https://github.com/inspiros/kap/actions) [![PyPI](https://img.shields.io/pypi/v/kap)](https://pypi.org/project/kap) [![Downloads](https://static.pepy.tech/badge/kap)](https://pepy.tech/project/kap) [![License](https://img.shields.io/github/license/inspiros/kap)](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [![DOI](https://zenodo.org/badge/731651337.svg)](https://zenodo.org/doi/10.5281/zenodo.10449790)
39
+ [![Build wheels](https://github.com/inspiros/kap/actions/workflows/build_wheels.yml/badge.svg)](https://github.com/inspiros/kap/actions) [![PyPI](https://img.shields.io/pypi/v/kap)](https://pypi.org/project/kap) [![Downloads](https://static.pepy.tech/badge/kap)](https://pepy.tech/project/kap) [![License](https://img.shields.io/github/license/inspiros/kap)](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [![DOI](https://zenodo.org/badge/731651337.svg)](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.7+**
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
- Simply run the following command:
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]], dtype=int64), matching_costs=array([4, 4, 3]), free=[])
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 $n (n - 1) / 2$ 2D cost matrices of pairwise matching costs between $n$ partites
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.combination(n, 2)``. For example, $[C_{01}, C_{02}, C_{03}, C_{12}, C_{13}, C_{23}]$.
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
- It's return type shares the same structure as that of ``kap.linear_assignment`` but with some small differences:
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 3 are matched together.
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{inspiros_2024_10449791,
207
+ @software{tran_2026_19097964,
188
208
  author = {Hoang-Nhat Tran},
189
- title = {inspiros/kap: v0.2.1},
190
- month = jan,
191
- year = 2024,
209
+ title = {inspiros/kap: v0.2.2},
210
+ month = mar,
211
+ year = 2026,
192
212
  publisher = {Zenodo},
193
- version = {v0.2.1},
194
- doi = {10.5281/zenodo.10449791},
195
- url = {https://doi.org/10.5281/zenodo.10449791}
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
- [![Build wheels](https://github.com/inspiros/kap/actions/workflows/build_wheels.yml/badge.svg)](https://github.com/inspiros/kap/actions) [![PyPI](https://img.shields.io/pypi/v/kap)](https://pypi.org/project/kap) [![Downloads](https://static.pepy.tech/badge/kap)](https://pepy.tech/project/kap) [![License](https://img.shields.io/github/license/inspiros/kap)](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [![DOI](https://zenodo.org/badge/731651337.svg)](https://zenodo.org/doi/10.5281/zenodo.10449790)
3
+ [![Build wheels](https://github.com/inspiros/kap/actions/workflows/build_wheels.yml/badge.svg)](https://github.com/inspiros/kap/actions) [![PyPI](https://img.shields.io/pypi/v/kap)](https://pypi.org/project/kap) [![Downloads](https://static.pepy.tech/badge/kap)](https://pepy.tech/project/kap) [![License](https://img.shields.io/github/license/inspiros/kap)](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [![DOI](https://zenodo.org/badge/731651337.svg)](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.7+**
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
- Simply run the following command:
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]], dtype=int64), matching_costs=array([4, 4, 3]), free=[])
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 $n (n - 1) / 2$ 2D cost matrices of pairwise matching costs between $n$ partites
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.combination(n, 2)``. For example, $[C_{01}, C_{02}, C_{03}, C_{12}, C_{13}, C_{23}]$.
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
- It's return type shares the same structure as that of ``kap.linear_assignment`` but with some small differences:
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 3 are matched together.
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{inspiros_2024_10449791,
171
+ @software{tran_2026_19097964,
155
172
  author = {Hoang-Nhat Tran},
156
- title = {inspiros/kap: v0.2.1},
157
- month = jan,
158
- year = 2024,
173
+ title = {inspiros/kap: v0.2.2},
174
+ month = mar,
175
+ year = 2026,
159
176
  publisher = {Zenodo},
160
- version = {v0.2.1},
161
- doi = {10.5281/zenodo.10449791},
162
- url = {https://doi.org/10.5281/zenodo.10449791}
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 = np.asarray(M)[:, ::-1]
34
+ M = M[:, ::-1]
32
35
  if M is not None:
33
- return p1, p2, np.asarray(M)
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[i, j].copy())
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, matching_filter,
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, matching_filter,
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
- best_Gq = Gq
539
- best_score = Gq.vertices_cost()
540
- best_first_partites_pair = choices[0]
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, Gq, best_first_partites_pair, maximize, max_iter,
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(Gq.vertices())
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 = [Gq.vertices()]
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.2
3
+ Version: 0.2.3
4
4
  Summary: Gabrovšek's Multiple Hungarian method for solving k-Assignment Problem.
5
- Home-page: https://github.com/inspiros/kap
6
- Author: Hoang-Nhat Tran (inspiros)
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.7
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
- [![Build wheels](https://github.com/inspiros/kap/actions/workflows/build_wheels.yml/badge.svg)](https://github.com/inspiros/kap/actions) [![PyPI](https://img.shields.io/pypi/v/kap)](https://pypi.org/project/kap) [![Downloads](https://static.pepy.tech/badge/kap)](https://pepy.tech/project/kap) [![License](https://img.shields.io/github/license/inspiros/kap)](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [![DOI](https://zenodo.org/badge/731651337.svg)](https://zenodo.org/doi/10.5281/zenodo.10449790)
39
+ [![Build wheels](https://github.com/inspiros/kap/actions/workflows/build_wheels.yml/badge.svg)](https://github.com/inspiros/kap/actions) [![PyPI](https://img.shields.io/pypi/v/kap)](https://pypi.org/project/kap) [![Downloads](https://static.pepy.tech/badge/kap)](https://pepy.tech/project/kap) [![License](https://img.shields.io/github/license/inspiros/kap)](https://github.com/inspiros/kap/blob/master/LICENSE.txt) [![DOI](https://zenodo.org/badge/731651337.svg)](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.7+**
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
- Simply run the following command:
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]], dtype=int64), matching_costs=array([4, 4, 3]), free=[])
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 $n (n - 1) / 2$ 2D cost matrices of pairwise matching costs between $n$ partites
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.combination(n, 2)``. For example, $[C_{01}, C_{02}, C_{03}, C_{12}, C_{13}, C_{23}]$.
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
- It's return type shares the same structure as that of ``kap.linear_assignment`` but with some small differences:
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 3 are matched together.
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{inspiros_2024_10449791,
207
+ @software{tran_2026_19097964,
188
208
  author = {Hoang-Nhat Tran},
189
- title = {inspiros/kap: v0.2.1},
190
- month = jan,
191
- year = 2024,
209
+ title = {inspiros/kap: v0.2.2},
210
+ month = mar,
211
+ year = 2026,
192
212
  publisher = {Zenodo},
193
- version = {v0.2.1},
194
- doi = {10.5281/zenodo.10449791},
195
- url = {https://doi.org/10.5281/zenodo.10449791}
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
- setup.cfg
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
@@ -0,0 +1,20 @@
1
+ numpy
2
+
3
+ [lap]
4
+ lap
5
+
6
+ [lapjv]
7
+ lapjv
8
+
9
+ [munkres]
10
+ munkres
11
+
12
+ [scipy]
13
+ scipy
14
+
15
+ [test]
16
+ pytest
17
+ scipy
18
+ lap
19
+ lapjv
20
+ munkres
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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