|
| 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 |
0 commit comments