-
Notifications
You must be signed in to change notification settings - Fork 31
/
Copy pathhierarchical.py
401 lines (293 loc) · 14.6 KB
/
hierarchical.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
import marimo
__generated_with = "0.9.33"
app = marimo.App(width="medium")
@app.cell
def __():
from mosek.fusion import Model, Domain, Expr, ObjectiveSense
import mosek.fusion.pythonic
import time
import numpy as np
import matplotlib.pyplot as plt
import marimo as mo
import sys
np.random.seed(5)
return (
Domain,
Expr,
Model,
ObjectiveSense,
mo,
mosek,
np,
plt,
sys,
time,
)
@app.cell(hide_code=True)
def __(mo):
mo.md(
"""
Hierarchical model structure allows users to consider multiple objectives, and the trade-offs in their models with a simple approach.
In health-care scheduling it is not unsual to see objectives with more than one focus points. And in this example, we are showing a hierarchical model structure where the decisions are made minimizing the overall cost while increasing the overall affinity.
Let's say we want to assign a set of healthcare workers, $w \in W$, to a set of patients, $p \in P$. For simplicity, the set sizes of both groups are the same, and the assignments of healthcare workers to patients are done one-to-one. For each worker assigned to a particular patient, there is a cost of assignment, $c_{wp}$. And the proximity of workers to patients is measured with the affinity parameter, $a_{wp}$.
Firstly, we prioritize to minimize the costs, and do not consider the affinity measure. Then, we have a basic assignment problem on hand. Which can be modeled as follows:
$$
min \quad z = \sum_{w \in W} \sum_{p \in P} c_{wp} x_{wp}
$$
$$s.t. \sum_{w \in W} x_{wp} = 1 \quad \quad p \in P \quad\quad\quad (1)$$
$$
\sum_{p \in P} x_{wp} = 1 \quad\quad w\in W \quad\quad\quad (2)
$$
$$
x_{wp} \in \{0, 1\} \quad w \in W, \, p \in P
$$
"""
)
return
@app.cell
def __(mo):
mo.md("""Let's define the model using MOSEK Fusion API.""")
return
@app.cell
def __(Domain, Expr, Model, ObjectiveSense, cost, ones):
def minCostModel(N):
#Create the model, we call it M here, by calling the Model class
M = Model()
#The decision variable, x, is created using the Model.variable() function.
#Model.variable(variable_name, dimensions, variable type)
#As defined in the model, our W and P sets are equal sized and 2 dimensional.
x = M.variable("x", [N,N], Domain.binary())
#Model.constraint(constraint_name, expression)
#The expression is constructed with the dot product operator "@", to ensure a more efficient implementation.
#In constraint (1), the dot product will result in (sum of x[w,p] * ones[w,p] for each w), for each p.
M.constraint("Contraint (1)", x.T @ ones == 1) #column sum
#In constraint (2), the dot product will result in (sum of x[w,p] * ones[w,p] for each p), for each w.
M.constraint("Contraint (2)", x @ ones.T == 1) #row sum
#Model.objective(objective_function_name, objective,_type, expression)
#Expr.dot(a,b) conducts a element-wise matrix multiplication. Then sums the cells values and outputs a scalar value.
#Expr.dot(x,cost) : sum of x[w,p] * cost[w,p] for each w,p
M.objective("Minimum Cost Objective Function", ObjectiveSense.Minimize, Expr.dot(x, cost))
#solve the model
M.solve()
#retrieve the objective value
objective_value_init = M.primalObjValue()
M.writeTask("minCostModel.ptf")
print("Objective Value:", round(objective_value_init,4))
return objective_value_init
return (minCostModel,)
@app.cell(hide_code=True)
def __(mo):
mo.md("""The initial model can now be implemented. Firstly, select the number of desired workers and patients.""")
return
@app.cell(hide_code=True)
def __(mo):
NodeSize = mo.ui.number(10)
mo.md(f"Choose the number of workers/patients: {NodeSize})")
return (NodeSize,)
@app.cell(hide_code=True)
def __(NodeSize):
N = NodeSize.value
return (N,)
@app.cell(hide_code=True)
def __(mo):
mo.md("""Then, let's generate a data to use in our model. We randomly generate the cost matrix using a uniform distribution.""")
return
@app.cell
def __(N, np):
cost = np.random.uniform(low=10, high=50, size=(N, N))
ones = np.ones(N)
return cost, ones
@app.cell(hide_code=True)
def __(mo):
mo.md("""Now, we can run the model.""")
return
@app.cell
def __(N, minCostModel):
initialObjective = minCostModel(N)
return (initialObjective,)
@app.cell(hide_code=True)
def __(mo):
mo.md(
"""
With this solution, we have managed to obtain a minimum cost assignment. Now, we also want to consider the affinity of the assignments and maximize it. With hierarchical optimization, this approach can be implemented in a simple manner.
Let's call the minimum cost assignment value $z^*$. We can change the objective function to maximize the affinity while constraining the model with a cost upper bound. This approach will allow us to maximizing the total affinity while still maintaining the minimal cost.
The objective function maximizes the total affinity, while constraint (1) limits the total cost to be at most the retrieved minimal value. The rest of the constraints remain the same.
$$ max \quad w = \sum_{wp} a_{wp} x_{wp} $$
$$ \quad \sum_{wp} c_{wp} x_{wp} \leq z^* \quad\quad (1)$$
$$ \sum_{w} x_{wp} = 1, \quad p \in P \quad\quad (2) $$
$$ \sum_{p} x_{wp} = 1, \quad w \in W \quad\quad (3) $$
$$ x_{wp} \in \{0, 1\}, \quad w \in W, p \in P $$
One can also decide to trade off from the cost and prioritize the affinity more. To achieve this we can increase the cost by a fraction and resulting in a more relaxed constraint. Let's call this fraction, $a$. Then constraint (1), transforms into the following:
$$ \quad \sum_{wp} c_{wp} x_{wp} \leq (1+a)z^* \quad\quad (1)$$
"""
)
return
@app.cell(hide_code=True)
def __(mo):
mo.md("""Let's implement the Maximum Affinity - Minimum Cost Model according to this definition.""")
return
@app.cell
def __(Domain, Expr, Model, ObjectiveSense, affinity, cost, ones):
def maxAffinityMinCostModel(a, objective_value_init, N):
#Model definition, called m
M = Model()
#Binary variable x[w,p] is defined where w,p in N
x = M.variable("x", [N,N], Domain.binary())
#In constraint (1), the dot function will execute (sum of (x[w,p] * cost[w,p]) for each w,p).
#RHS is relaxed by "a" percent.
M.constraint("Constraint (1)", Expr.dot(x, cost) <= (1+a)*objective_value_init)
#In constraint (2), the dot product will result in (sum of x[w,p] * ones[w,p] for each w), for each p.
M.constraint("Constraint (2)", x.T @ ones == 1) #column sum
#In constraint (3), the dot product will result in (sum of x[w,p] * ones[w,p] for each p), for each w.
M.constraint("Constraint (3)", x @ ones.T == 1) #row sum
#Maximize the total affinifity, sum of x[w,p] * affinity[w,p] for each w,p
M.objective("Maximum Affinity Objective Function", ObjectiveSense.Maximize, Expr.dot(x, affinity))
M.solve()
#retrieve the objective value
objective_value = M.primalObjValue()
# print("Objective Value:", objective_value)
return M,objective_value
return (maxAffinityMinCostModel,)
@app.cell(hide_code=True)
def __(mo):
mo.md("""Let's generate a matrix for affinity data as well. We also create a list of $a$ values and make multiple trials with the values to observe the trade off between affinty and cost. The $a$ values range from 0 to maximum 1 incrementing by 0.05.""")
return
@app.cell(hide_code=True)
def __(mo):
slider = mo.ui.slider(0.05, 1,step=0.05,value=0.5, show_value=True)
mo.md(f"Choose the maximum alpha value for the trial range: {slider}")
return (slider,)
@app.cell(hide_code=True)
def __(slider):
alpha_range_input = slider.value
alphaRange = (alpha_range_input/0.05) + 1
return alphaRange, alpha_range_input
@app.cell
def __(N, alphaRange, np):
affinity = np.random.uniform(low=10, high=50, size=(N, N))
#The alpha values range from 0 to 1 incrementing by 0.1
alphas = [round(i * 0.05, 2) for i in range(0, int(alphaRange))]
return affinity, alphas
@app.cell
def __(alphas, maxAffinityMinCostModel, plt, time):
def RunMaximumAffinityModel(N,initialObjective):
#Record the retrieved affinities with the corresponding cost value
costs = []
affinities = []
start = time.time()
#Solve the model for every "a" value.
for alpha in alphas:
#Get the maximum affinity objective value
M, maxAff_objective_value = maxAffinityMinCostModel(alpha,initialObjective,N)
print("Objective value:", round(maxAff_objective_value,4), " Cost:", round((1+alpha)*initialObjective,4))
affinities.append(maxAff_objective_value)
costs.append((1+alpha)*initialObjective)
end = time.time()
#Plot the Cost/Affinity Scatter Plot
plt.scatter(costs, affinities)
plt.xlabel("Cost")
plt.ylabel("Affinity")
plt.show()
return (end-start)
return (RunMaximumAffinityModel,)
@app.cell
def __(N, RunMaximumAffinityModel, initialObjective):
runTime = RunMaximumAffinityModel(N,initialObjective)
return (runTime,)
@app.cell(hide_code=True)
def __(mo):
mo.md(
"""
This model cuırrently is solved in seconds, but recalculating and resolving it from scratch every time the cost upper bound changes becomes slower and less efficient as the number of workers and patients increases. Instead of starting the solution process anew each time, we can parametrize the model and use a previously found initial solution, which will significantly reduce the solution time for larger instances.
In parametrized models, the MOSEK Fusion Solver checks if the given solution is valid for the updated parameters. If so, it continues searching for the optimal value starting from the initial solution. Since we are only modifying the right-hand side of constraint (1), it is more logical to increase the $a$ values. By doing this, we relax the right-hand side and ensure the used initial solution value in other models (solution with the lowest $a$ parameter) being feasible with other parameters as well.
"""
)
return
@app.cell(hide_code=True)
def __(mo):
mo.md("""To make our model parametrized, we need to define a parameter variable. With this parameter update, if it is still applicable, the previous model solution is used.""")
return
@app.cell
def __(Domain, Expr, Model, ObjectiveSense, affinity, cost, np):
def maxAffinityMinCostModelParametrized(a,objective_value_init,N):
ones = np.ones(N)
#Model definition called m
M = Model()
#Binary variable x[w,p] is defined where w,p in N
x = M.variable("x", [N,N], Domain.binary())
#Parameter to adjust the cost
#Model.parameter(parameter_name, dimension)
a = M.parameter("a", 1)
#In constraint 1, the dot function will execute (sum of (x[w,p] * cost[w,p]) for each w,p).
#RHS is relaxed by "a" percent.
M.constraint("Constraint (1)", Expr.dot(x, cost) <= (1+a)*objective_value_init)
#In constraint (2), the dot product will result in each (sum of x[w,p] * ones[w,p] for each w), for each p.
M.constraint("Constraint (2)", x.T @ ones == 1) #column sum
#In constraint (3), the dot product will result in each (sum of x[w,p] * ones[w,p] for each p), for each w.
M.constraint("Constraint (3)", x @ ones.T == 1) #row sum
#Maximize the total affinifity, sum of x[w,p] * affinity[w,p] for each w,p
M.objective("Maximum Affinity Objective Function", ObjectiveSense.Maximize, Expr.dot(x, affinity))
M.solve()
return M
return (maxAffinityMinCostModelParametrized,)
@app.cell(hide_code=True)
def __(mo):
mo.md("""Now, our model is parametrized. Let's run the model with the same $a$ values again. The objective results should be the same.""")
return
@app.cell
def __(alphas, maxAffinityMinCostModelParametrized, plt, time):
def RunMaximumAffinityParametrized(N,initialObjective):
affinities = []
costs = []
start = time.time()
#Run the model once, get the model object
M = maxAffinityMinCostModelParametrized(0,initialObjective,N)
#Set the parameter
a_parameter = M.getParameter("a")
#Solve the model for every "a" value.
for alpha in alphas:
#Set parameter value according to chosen "a"
a_parameter.setValue(alpha)
#Reoptimize by calling the solve function
M.solve()
#Get the objective value
objective_value = M.primalObjValue()
print("Objective value:", round(objective_value,4), " Cost:", round((1+alpha)*initialObjective,4))
affinities.append(objective_value)
costs.append((1+alpha)*initialObjective)
end = time.time()
#Plot the Cost/Affinity Scatter Plot
plt.scatter(costs, affinities)
plt.xlabel("Cost")
plt.ylabel("Affinity")
plt.show()
return (end-start)
return (RunMaximumAffinityParametrized,)
@app.cell
def __(N, RunMaximumAffinityParametrized, initialObjective):
runtime_Parametrized = RunMaximumAffinityParametrized(N,initialObjective)
return (runtime_Parametrized,)
@app.cell(hide_code=True)
def __(mo):
mo.md(
"""
Let's test the runtime difference. You can change all the model outputs by using the interactive elements. Go to the top of the page and increase the worker/patient size. <br>
_Hint: Try using 200 worker/patients!_
"""
)
return
@app.cell
def __(runTime, runtime_Parametrized):
print("The runtime of non-parametrized Maximum Affinity Model: ", runTime, "s.")
print("The runtime of parametrized Maximum Affinity Model: ", runtime_Parametrized,"s.")
return
@app.cell(hide_code=True)
def __(mo):
mo.md(r"""You can also observe the trade-off relation between cost and affinity by changing the alpha range from the slider!""")
return
@app.cell(hide_code=True)
def __(mo):
mo.md(r"""This work is inspired by Ryan O'Neill's blog post about the implementation of hierarchical optimization. Click [here](https://ryanjoneil.github.io/posts/2024-11-08-hierarchical-optimization-with-gurobi/) to check out the blog post.""")
return
if __name__ == "__main__":
app.run()