Note
This is example is available as a Jupyter notebook. Download it and all
necessary data files here
.
Workforce Scheduling¶
This example implements a simple workforce scheduling model: assigning workers to shifts in order to maximize a total preference score.
[1]:
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
import gurobipy_pandas as gppd
pd.options.display.max_rows = 8
gppd.set_interactive()
First read in preference data. The preference data contains 3 columns: a shift date, worker name, and a preference value. If a worker is not available for a given shift, then that work-shift combination does not appear in the data (i.e. only preferenced shifts are valid).
[3]:
preferences = pd.read_csv(
"data/preferences.csv",
parse_dates=["Shift"],
index_col=['Worker', 'Shift']
)
preferences
[3]:
Preference | ||
---|---|---|
Worker | Shift | |
Amy | 2022-07-02 | 1 |
2022-07-03 | 3 | |
2022-07-05 | 2 | |
2022-07-07 | 2 | |
... | ... | ... |
Gu | 2022-07-11 | 2 |
2022-07-12 | 2 | |
2022-07-13 | 2 | |
2022-07-14 | 3 |
72 rows × 1 columns
Unstacking the data, we can see that the dataset is sparse: not all worker-shift combinations are possible. When constructing the model, we should take care that decision variables are only created for the valid combinations.
[4]:
preferences.unstack(0).head()
[4]:
Preference | |||||||
---|---|---|---|---|---|---|---|
Worker | Amy | Bob | Cathy | Dan | Ed | Fred | Gu |
Shift | |||||||
2022-07-01 | NaN | 2.0 | NaN | NaN | 1.0 | 3.0 | 2.0 |
2022-07-02 | 1.0 | 2.0 | NaN | 2.0 | 1.0 | 3.0 | 2.0 |
2022-07-03 | 3.0 | NaN | 1.0 | 3.0 | 3.0 | 1.0 | 2.0 |
2022-07-04 | NaN | NaN | 1.0 | NaN | 3.0 | NaN | NaN |
2022-07-05 | 2.0 | 3.0 | 2.0 | 2.0 | 2.0 | NaN | 3.0 |
Next load the shift requirements data, which indicates the number of required workers for each shift.
[5]:
shift_requirements = pd.read_csv(
"data/shift_requirements.csv",
parse_dates=["Shift"],
index_col="Shift"
)
shift_requirements
[5]:
Required | |
---|---|
Shift | |
2022-07-01 | 3 |
2022-07-02 | 2 |
2022-07-03 | 4 |
2022-07-04 | 2 |
... | ... |
2022-07-11 | 4 |
2022-07-12 | 5 |
2022-07-13 | 7 |
2022-07-14 | 5 |
14 rows × 1 columns
Model Formulation¶
Our goal is to fill all available shifts with the required number of workers, while maximising the sum of preference values over all assignments. To do this, will create a binary variable for each valid worker-shift pairing (1 = shift assigned) and use preference values as linear coefficients in the objective.
There are three pandas indices involved in creating this model: workers, shifts, and preference pairings. While variables are added for the preference pairings, we will see that the worker and shift indexes emerge when aggregating.
[6]:
m = gp.Model()
df = (
preferences
.gppd.add_vars(
m, name="assign", vtype=GRB.BINARY, obj="Preference",
index_formatter={"Shift": lambda index: index.strftime('%a%d')},
)
)
df
[6]:
Preference | assign | ||
---|---|---|---|
Worker | Shift | ||
Amy | 2022-07-02 | 1 | <gurobi.Var assign[Amy,Sat02]> |
2022-07-03 | 3 | <gurobi.Var assign[Amy,Sun03]> | |
2022-07-05 | 2 | <gurobi.Var assign[Amy,Tue05]> | |
2022-07-07 | 2 | <gurobi.Var assign[Amy,Thu07]> | |
... | ... | ... | ... |
Gu | 2022-07-11 | 2 | <gurobi.Var assign[Gu,Mon11]> |
2022-07-12 | 2 | <gurobi.Var assign[Gu,Tue12]> | |
2022-07-13 | 2 | <gurobi.Var assign[Gu,Wed13]> | |
2022-07-14 | 3 | <gurobi.Var assign[Gu,Thu14]> |
72 rows × 2 columns
By grouping variables across the shift indices, we can efficiently construct the shift requirement constraints.
[7]:
shift_cover = gppd.add_constrs(
m,
df['assign'].groupby('Shift').sum(),
GRB.EQUAL,
shift_requirements["Required"],
name="shift_cover",
index_formatter={"Shift": lambda index: index.strftime('%a%d')},
)
shift_cover
[7]:
Shift
2022-07-01 <gurobi.Constr shift_cover[Fri01]>
2022-07-02 <gurobi.Constr shift_cover[Sat02]>
2022-07-03 <gurobi.Constr shift_cover[Sun03]>
2022-07-04 <gurobi.Constr shift_cover[Mon04]>
...
2022-07-11 <gurobi.Constr shift_cover[Mon11]>
2022-07-12 <gurobi.Constr shift_cover[Tue12]>
2022-07-13 <gurobi.Constr shift_cover[Wed13]>
2022-07-14 <gurobi.Constr shift_cover[Thu14]>
Name: shift_cover, Length: 14, dtype: object
Extracting Solutions¶
With the model formulated, we solve it using the Gurobi Optimizer:
[8]:
m.optimize()
Gurobi Optimizer version 12.0.0 build v12.0.0rc1 (linux64 - "Ubuntu 22.04.3 LTS")
CPU model: Intel(R) Xeon(R) Platinum 8175M CPU @ 2.50GHz, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 1 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 14 rows, 72 columns and 72 nonzeros
Model fingerprint: 0xf76e3721
Variable types: 0 continuous, 72 integer (72 binary)
Coefficient statistics:
Matrix range [1e+00, 1e+00]
Objective range [1e+00, 3e+00]
Bounds range [1e+00, 1e+00]
RHS range [2e+00, 7e+00]
Found heuristic solution: objective 107.0000000
Presolve removed 14 rows and 72 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 96 107
Optimal solution found (tolerance 1.00e-04)
Best objective 9.600000000000e+01, best bound 9.600000000000e+01, gap 0.0000%
Extract the solution using the series accessor .gppd.X
:
[9]:
solution = df['assign'].gppd.X
solution
[9]:
Worker Shift
Amy 2022-07-02 1.0
2022-07-03 0.0
2022-07-05 1.0
2022-07-07 1.0
...
Gu 2022-07-11 1.0
2022-07-12 1.0
2022-07-13 1.0
2022-07-14 0.0
Name: assign, Length: 72, dtype: float64
Since the result is returned as a pandas series, we can easily filter down to the selected assignments:
[10]:
assigned_shifts = solution.reset_index().query("assign == 1")
assigned_shifts
[10]:
Worker | Shift | assign | |
---|---|---|---|
0 | Amy | 2022-07-02 | 1.0 |
2 | Amy | 2022-07-05 | 1.0 |
3 | Amy | 2022-07-07 | 1.0 |
4 | Amy | 2022-07-09 | 1.0 |
... | ... | ... | ... |
67 | Gu | 2022-07-10 | 1.0 |
68 | Gu | 2022-07-11 | 1.0 |
69 | Gu | 2022-07-12 | 1.0 |
70 | Gu | 2022-07-13 | 1.0 |
52 rows × 3 columns
Additionally, we can unstack the result and transform it to produce a roster table:
[11]:
shift_table = solution.unstack(0).fillna("-").replace({0.0: "-", 1.0: "Y"})
pd.options.display.max_rows = 15
shift_table
[11]:
Worker | Amy | Bob | Cathy | Dan | Ed | Fred | Gu |
---|---|---|---|---|---|---|---|
Shift | |||||||
2022-07-01 | - | Y | - | - | Y | - | Y |
2022-07-02 | Y | - | - | - | Y | - | - |
2022-07-03 | - | - | Y | - | Y | Y | Y |
2022-07-04 | - | - | Y | - | Y | - | - |
2022-07-05 | Y | - | Y | Y | Y | - | Y |
2022-07-06 | - | Y | - | Y | - | Y | Y |
2022-07-07 | Y | - | Y | - | Y | - | Y |
2022-07-08 | - | Y | - | - | Y | - | - |
2022-07-09 | Y | - | - | - | Y | - | - |
2022-07-10 | - | - | Y | Y | - | - | Y |
2022-07-11 | - | Y | - | Y | Y | - | Y |
2022-07-12 | Y | - | Y | Y | - | Y | Y |
2022-07-13 | Y | Y | Y | Y | Y | Y | Y |
2022-07-14 | Y | - | Y | Y | Y | Y | - |