Integer programming: the assignment problem¶
In [93]:
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
from itertools import product
import networkx as nx
from networkx.algorithms import bipartite
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import scipy
The functions below creates a networkx graph from an matrix with an assignment problem data and plots the graph.
In [94]:
def graph_from_scores(scores):
"""
Given an array with scores of the assignment problem
returns the corresponding weighted networkx graph.
Column nodes are labeled C1, C2, etc., row nodes P1, P2, etc.
scores:
A 2-dimensional array with the assignment problem data.
Returns:
A networkx weighted graph object.
"""
df = pd.DataFrame(scores,
index=[f"P{i}" for i in range(1, scores.shape[0] + 1)],
columns=[f"C{i}" for i in range(1, scores.shape[1] + 1)])
B = nx.Graph() # Add nodes with the node attribute "bipartite"
B.add_nodes_from([f"C{i}" for i in range(1, scores.shape[1] + 1)], bipartite=0)
B.add_nodes_from([f"P{i}" for i in range(1, scores.shape[0] + 1)], bipartite=1)
for p, c in product(df.index, df.columns):
if not np.isnan(w := df.at[p, c]):
B.add_edge(p, c, weight=w)
return df, B
def plot_assignment_graph(B):
"""
Plot the graph for an assignment problem.
B:
A networkx object representing a weighted bipartite graph.
Returns:
None.
"""
plt.figure(figsize=(10, 5))
node_color = ['lightcoral' if n.startswith("C") else 'lightsteelblue' for n in B.nodes]
nx.draw_networkx(
B,
pos = nx.drawing.layout.bipartite_layout(B, nodes = [n for n in B.nodes if n.startswith("C")]),
width = 3,
node_color=node_color,
node_size=1000
)
plt.show()
Create and plot the bipartite graph:
In [95]:
scores = np.array(
[[70, 90, np.nan, 75, 55, np.nan, 60],
[40, 95, 85, np.nan, np.nan, 80, np.nan],
[50, np.nan, 75, np.nan, 70, np.nan, 65],
[np.nan, np.nan, 60, 80, np.nan, 35, np.nan],
[np.nan, 75, np.nan, 70, np.nan, 35, 20]])
df, B = graph_from_scores(scores)
df = df.fillna("")
display(df)
plot_assignment_graph(B)
| C1 | C2 | C3 | C4 | C5 | C6 | C7 | |
|---|---|---|---|---|---|---|---|
| P1 | 70.0 | 90.0 | 75.0 | 55.0 | 60.0 | ||
| P2 | 40.0 | 95.0 | 85.0 | 80.0 | |||
| P3 | 50.0 | 75.0 | 70.0 | 65.0 | |||
| P4 | 60.0 | 80.0 | 35.0 | ||||
| P5 | 75.0 | 70.0 | 35.0 | 20.0 |
Solving the linear program for the assignment problem¶
The next function solves an assignment problem, using linear programming:
In [96]:
def lp_assignment(B):
"""
Sets up and solves the assignment problem for
a given weighted bipartite graph.
B:
A networkx object representing a weighted bipartite graph.
Returns:
The data with the linear program solution returned by
scipy.optimize.linprog.
"""
weighted_incidence = nx.incidence_matrix(B, weight="weight").todense()
edge_weights = weighted_incidence.max(axis=0)
incidence = (weighted_incidence > 0).astype(int)
num_slacks = len([n for n in B.nodes if n.startswith("C")])
c = -edge_weights
A_ub = incidence[:num_slacks]
b_ub = np.ones(num_slacks)
A_eq = incidence[num_slacks:]
b_eq = np.ones(len(B.nodes) - num_slacks)
sp = scipy.optimize.linprog(c=c,
A_ub=A_ub,
b_ub=b_ub,
A_eq=A_eq,
b_eq=b_eq)
return sp
In [97]:
sol = lp_assignment(B)
sol
Out[97]:
message: Optimization terminated successfully. (HiGHS Status 7: Optimal)
success: True
status: 0
fun: -380.0
x: [ 1.000e+00 0.000e+00 ... 0.000e+00 0.000e+00]
nit: 9
lower: residual: [ 1.000e+00 0.000e+00 ... 0.000e+00
0.000e+00]
marginals: [ 0.000e+00 4.000e+01 ... 5.000e+00
3.500e+01]
upper: residual: [ inf inf ... inf
inf]
marginals: [ 0.000e+00 0.000e+00 ... 0.000e+00
0.000e+00]
eqlin: residual: [ 0.000e+00 0.000e+00 0.000e+00 0.000e+00
0.000e+00]
marginals: [-7.000e+01 -8.000e+01 -7.000e+01 -6.500e+01
-5.500e+01]
ineqlin: residual: [ 0.000e+00 0.000e+00 0.000e+00 0.000e+00
1.000e+00 0.000e+00 1.000e+00]
marginals: [-0.000e+00 -2.000e+01 -5.000e+00 -1.500e+01
-0.000e+00 -0.000e+00 -0.000e+00]
mip_node_count: 0
mip_dual_bound: 0.0
mip_gap: 0.0
Note that the solution consists of integers only:
In [98]:
sol.x
Out[98]:
array([ 1., 0., 0., -0., 0., 1., -0., 1., 0., 0., 1., -0., 0.,
0., 1., 0., 0., 0., 0., 0.])
To get a readable solution of the assignment problem, it is now enough to print information about the edges of the graph for which the linear program solution is equal to 1:
In [85]:
edges = list(B.edges(data=True))
assignment = [edges[i] for i in np.nonzero(sol.x)[0]]
assignment
Out[85]:
[('C1', 'P1', {'weight': 70.0}),
('C2', 'P5', {'weight': 75.0}),
('C3', 'P3', {'weight': 75.0}),
('C4', 'P4', {'weight': 80.0}),
('C6', 'P2', {'weight': 80.0})]
The sum of weights of the optimal assignment:
In [86]:
sum([w["weight"] for c, p, w in assignment])
Out[86]:
380.0
Assignment with random graphs¶
Create a random score matrix and solve the assignment problem for this matrix:
In [103]:
rng = np.random.default_rng()
cnum = 15
pnum = 7
edge_prob = 0.7
random_scores = rng.integers(0, 100, (pnum, cnum)).astype(float)
mask = rng.random((pnum, cnum)) > edge_prob
random_scores[mask] = np.nan
df, B = graph_from_scores(random_scores)
df = df.fillna("")
display(df)
#plot_assignment_graph(B)
sol = lp_assignment(B)
print(f"Linear program solution:\n{sol.x}")
edges = list(B.edges(data=True))
assignment = [edges[i] for i in np.nonzero(sol.x)[0]]
print("\nOptimal assignment:")
assignment
| C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | C13 | C14 | C15 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| P1 | 57.0 | 58.0 | 4.0 | 89.0 | 81.0 | 57.0 | 18.0 | 31.0 | 13.0 | 67.0 | |||||
| P2 | 59.0 | 89.0 | 2.0 | 81.0 | 94.0 | 94.0 | 97.0 | 55.0 | 81.0 | ||||||
| P3 | 2.0 | 2.0 | 1.0 | 2.0 | 38.0 | 44.0 | 54.0 | 59.0 | 35.0 | 23.0 | 68.0 | 90.0 | |||
| P4 | 5.0 | 54.0 | 83.0 | 65.0 | 31.0 | 46.0 | 51.0 | 4.0 | 96.0 | ||||||
| P5 | 20.0 | 93.0 | 45.0 | 47.0 | 5.0 | 53.0 | 28.0 | 30.0 | 99.0 | 6.0 | 45.0 | 22.0 | 53.0 | ||
| P6 | 92.0 | 99.0 | 49.0 | 54.0 | 99.0 | 34.0 | 32.0 | 36.0 | 61.0 | 4.0 | |||||
| P7 | 20.0 | 41.0 | 31.0 | 28.0 | 44.0 | 10.0 | 36.0 | 57.0 | 6.0 | 56.0 |
Linear program solution: [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0. -0. 0. 0. 0. -0. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. -0. 0. 1. 0. 0. 0. 0. 0. 0. -0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0. -0. 1. 0.] Optimal assignment:
Out[103]:
[('C3', 'P6', {'weight': 99.0}),
('C7', 'P1', {'weight': 89.0}),
('C8', 'P2', {'weight': 94.0}),
('C10', 'P5', {'weight': 99.0}),
('C12', 'P7', {'weight': 57.0}),
('C14', 'P3', {'weight': 68.0}),
('C15', 'P4', {'weight': 96.0})]