Skip to content

Commit bbd8386

Browse files
committed
Add Hungarian algorithm for the linear assignment problem.
1 parent 474b4fd commit bbd8386

3 files changed

Lines changed: 363 additions & 0 deletions

File tree

optax/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@
178178
from optax._src.wrappers import ShouldSkipUpdateFunction
179179
from optax._src.wrappers import skip_large_updates
180180
from optax._src.wrappers import skip_not_finite
181+
from optax.assignment._hungarian_algorithm import hungarian_algorithm
181182

182183

183184
# TODO(mtthss): remove tree_utils aliases after updates.
@@ -340,6 +341,7 @@
340341
"GradientTransformationExtraArgs",
341342
"hinge_loss",
342343
"huber_loss",
344+
"hungarian_algorithm",
343345
"identity",
344346
"incremental_update",
345347
"inject_hyperparams",
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Copyright 2024 DeepMind Technologies Limited. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
# ==============================================================================
15+
"""The Hungarian algorithm for the linear assignment problem."""
16+
17+
from functools import partial
18+
19+
import jax
20+
from jax import lax, numpy as jnp
21+
22+
23+
def hungarian_algorithm(costs, /):
24+
"""The Hungarian algorithm for the linear assignment problem.
25+
26+
The assignment problem is a fundamental combinatorial optimization problem.
27+
In this problem, there are :math:`n` workers and :math:`m` jobs.
28+
For each worker and job, there is a cost associated with assigning that worker
29+
to that job.
30+
The goal is to assign at most one worker to each job and at most one job to
31+
each worker, in a way that minimizes the total cost of the assignment.
32+
33+
Equivalently, given a weighted complete bipartite graph, the problem is to
34+
find a matching that minimizes the sum of the weights of the edges.
35+
36+
The Hungarian algorithm is an :math:`O(n^3)` algorithm for this problem.
37+
38+
Args:
39+
costs: A matrix of costs.
40+
41+
Returns:
42+
A pair ``(i, j)`` where ``i`` is an array of row indices and ``j`` is an
43+
array of column indices.
44+
The cost of the assignment is ``cost_matrix[i, j].sum()``.
45+
"""
46+
47+
if costs.shape[0] == 0 or costs.shape[1] == 0:
48+
return jnp.zeros(0, int), jnp.zeros(0, int)
49+
50+
transpose = costs.shape[1] < costs.shape[0]
51+
52+
if transpose:
53+
costs = costs.T
54+
55+
costs = costs.astype(float)
56+
u = jnp.zeros(costs.shape[0], costs.dtype)
57+
v = jnp.zeros(costs.shape[1], costs.dtype)
58+
59+
path = jnp.full(costs.shape[1], -1)
60+
col4row = jnp.full(costs.shape[0], -1)
61+
row4col = jnp.full(costs.shape[1], -1)
62+
63+
init = costs, u, v, path, row4col, col4row
64+
costs, u, v, path, row4col, col4row = lax.fori_loop(
65+
0, costs.shape[0], _lsa_body, init
66+
)
67+
68+
if transpose:
69+
i = col4row.argsort()
70+
return col4row[i], i
71+
else:
72+
return jnp.arange(costs.shape[0]), col4row
73+
74+
75+
def _find_short_augpath_while_body_inner_for(it, val):
76+
(
77+
remaining,
78+
min_value,
79+
costs,
80+
i,
81+
u,
82+
v,
83+
shortest_path_costs,
84+
path,
85+
lowest,
86+
row4col,
87+
index,
88+
) = val
89+
90+
j = remaining[it]
91+
92+
r = min_value + costs[i, j] - u[i] - v[j]
93+
94+
path = path.at[j].set(jnp.where(r < shortest_path_costs[j], i, path[j]))
95+
96+
shortest_path_costs = shortest_path_costs.at[j].min(r)
97+
98+
index = jnp.where(
99+
(shortest_path_costs[j] < lowest)
100+
| ((shortest_path_costs[j] == lowest) & (row4col[j] == -1)),
101+
it,
102+
index,
103+
)
104+
105+
lowest = jnp.minimum(lowest, shortest_path_costs[j])
106+
107+
return (
108+
remaining,
109+
min_value,
110+
costs,
111+
i,
112+
u,
113+
v,
114+
shortest_path_costs,
115+
path,
116+
lowest,
117+
row4col,
118+
index,
119+
)
120+
121+
122+
def _find_short_augpath_while_body_tail_alt(val):
123+
remaining, index, row4col, sink, i, sc, num_remaining = val
124+
125+
j = remaining[index]
126+
pred = row4col[j] == -1
127+
sink = jnp.where(pred, j, sink)
128+
i = jnp.where(pred, i, row4col[j])
129+
130+
sc = sc.at[j].set(True)
131+
num_remaining -= 1
132+
remaining = remaining.at[index].set(remaining[num_remaining])
133+
134+
return remaining, sink, i, sc, num_remaining
135+
136+
137+
def _find_short_augpath_while_body(val):
138+
(
139+
costs,
140+
u,
141+
v,
142+
path,
143+
row4col,
144+
current_row,
145+
min_value,
146+
num_remaining,
147+
remaining,
148+
sr,
149+
sc,
150+
shortest_path_costs,
151+
sink,
152+
) = val
153+
154+
index = -1
155+
lowest = jnp.inf
156+
sr = sr.at[current_row].set(True)
157+
158+
init = (
159+
remaining,
160+
min_value,
161+
costs,
162+
current_row,
163+
u,
164+
v,
165+
shortest_path_costs,
166+
path,
167+
lowest,
168+
row4col,
169+
index,
170+
)
171+
output = lax.fori_loop(
172+
0, num_remaining, _find_short_augpath_while_body_inner_for, init
173+
)
174+
(
175+
remaining,
176+
min_value,
177+
costs,
178+
current_row,
179+
u,
180+
v,
181+
shortest_path_costs,
182+
path,
183+
lowest,
184+
row4col,
185+
index,
186+
) = output
187+
188+
min_value = lowest
189+
# infeasible costs matrix
190+
sink = jnp.where(min_value == jnp.inf, -1, sink)
191+
192+
state = remaining, index, row4col, sink, current_row, sc, num_remaining
193+
(remaining, sink, current_row, sc, num_remaining) = jax.tree.map(
194+
partial(jnp.where, sink == -1),
195+
_find_short_augpath_while_body_tail_alt(state),
196+
(remaining, sink, current_row, sc, num_remaining),
197+
)
198+
199+
return (
200+
costs,
201+
u,
202+
v,
203+
path,
204+
row4col,
205+
current_row,
206+
min_value,
207+
num_remaining,
208+
remaining,
209+
sr,
210+
sc,
211+
shortest_path_costs,
212+
sink,
213+
)
214+
215+
216+
def _find_augmenting_path(costs, u, v, path, row4col, current_row):
217+
min_value = 0
218+
num_remaining = costs.shape[1]
219+
remaining = jnp.arange(costs.shape[1])[::-1]
220+
221+
sr = jnp.zeros(costs.shape[0], bool)
222+
sc = jnp.zeros(costs.shape[1], bool)
223+
224+
shortest_path_costs = jnp.full(costs.shape[1], jnp.inf)
225+
226+
sink = -1
227+
228+
init = (
229+
costs,
230+
u,
231+
v,
232+
path,
233+
row4col,
234+
current_row,
235+
min_value,
236+
num_remaining,
237+
remaining,
238+
sr,
239+
sc,
240+
shortest_path_costs,
241+
sink,
242+
)
243+
output = lax.while_loop(
244+
lambda val: val[-1] == -1, _find_short_augpath_while_body, init
245+
)
246+
(
247+
costs,
248+
u,
249+
v,
250+
path,
251+
row4col,
252+
current_row,
253+
min_value,
254+
num_remaining,
255+
remaining,
256+
sr,
257+
sc,
258+
shortest_path_costs,
259+
sink,
260+
) = output
261+
262+
return sink, min_value, sr, sc, shortest_path_costs, path
263+
264+
265+
def _lsa_body(current_row, val):
266+
costs, u, v, path, row4col, col4row = val
267+
268+
sink, min_value, sr, sc, shortest_path_costs, path = _find_augmenting_path(
269+
costs, u, v, path, row4col, current_row
270+
)
271+
272+
u = u.at[current_row].add(min_value)
273+
274+
mask = sr & (jnp.arange(costs.shape[0]) != current_row)
275+
u = jnp.where(mask, u + min_value - shortest_path_costs[col4row], u)
276+
277+
v = jnp.where(sc, v + shortest_path_costs - min_value, v)
278+
279+
def augment(carry):
280+
sink, row4col, col4row, _ = carry
281+
i = path[sink]
282+
row4col = row4col.at[sink].set(i)
283+
col4row, sink = col4row.at[i].set(sink), col4row[i]
284+
breakvar = i == current_row
285+
return sink, row4col, col4row, breakvar
286+
287+
sink, row4col, col4row, _ = lax.while_loop(
288+
lambda val: ~val[-1], augment, (sink, row4col, col4row, False)
289+
)
290+
291+
return costs, u, v, path, row4col, col4row
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright 2024 DeepMind Technologies Limited. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
# ==============================================================================
15+
"""Tests for the Hungarian algorithm."""
16+
17+
from absl.testing import absltest
18+
from absl.testing import parameterized
19+
from jax import random, numpy as jnp
20+
import scipy
21+
22+
from optax.assignment._hungarian_algorithm import hungarian_algorithm
23+
24+
25+
class HungarianAlgorithmTest(parameterized.TestCase):
26+
27+
@parameterized.product(
28+
n=[0, 1, 2, 4, 8, 16],
29+
m=[0, 1, 2, 4, 8, 16],
30+
)
31+
def test(self, n, m):
32+
33+
def test_hungarian_algorithm(costs):
34+
i, j = hungarian_algorithm(costs)
35+
36+
r = min(costs.shape)
37+
assert i.shape == (r,)
38+
assert j.shape == (r,)
39+
40+
assert jnp.issubdtype(i.dtype, jnp.integer)
41+
assert jnp.issubdtype(j.dtype, jnp.integer)
42+
43+
assert jnp.all(0 <= i)
44+
assert jnp.all(0 <= j)
45+
46+
assert (i < costs.shape[0]).all()
47+
assert (j < costs.shape[1]).all()
48+
49+
x = jnp.zeros(costs.shape[0], int).at[i].add(1)
50+
assert (x <= 1).all()
51+
assert x.sum() == r
52+
53+
y = jnp.zeros(costs.shape[1], int).at[j].add(1)
54+
assert (y <= 1).all()
55+
assert y.sum() == r
56+
57+
cost_optax = costs[i, j].sum()
58+
59+
i_scipy, j_scipy = scipy.optimize.linear_sum_assignment(costs)
60+
cost_scipy = costs[i_scipy, j_scipy].sum()
61+
62+
assert jnp.isclose(cost_optax, cost_scipy)
63+
64+
key = random.key(0)
65+
costs = random.normal(key, (n, m))
66+
test_hungarian_algorithm(costs)
67+
68+
69+
if __name__ == '__main__':
70+
absltest.main()

0 commit comments

Comments
 (0)