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 -