In [1]:
from pulp import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from scipy.optimize import minimize

%matplotlib inline


The following model shows how to calculate two models for scheduling call center agents given some (estimated) demand for customer calls or contacts for a given week.

We differentiate between permanent employees and temporary call center employees that have different efficiencies, measured in calls per hour, as well cost (per hour).

• We estimate the overall labor requirements by type, and we also
• Estimate the requirements by type by day given a distribution of calls per day

We'll use the PuLP library to do the optimization calculation for us using integer programming and discrete optimization.

### Inputs¶

In [2]:
total_calls = 7000
hours_per_shift = 9

labor_types = ['perm', 'temp']

# Minimum and maximum number of agents per type
min_agents = {'perm': 10, 'temp': 0}
max_agents = {'perm': 50, 'temp': 30}

# Efficiency assumptions
# We assume 5 calls per permanent agent and 3 per temp agent
calls_per_hour_per_agent = {'perm': 5, 'temp': 3}

# The hourly cost in \$ per agent per type
cost_per_agent_hour = {'perm': 20, 'temp': 10}

# We assume agents work 5 day weeks
days_per_week = 5

# To simplify things, we calculate the number of calls an agent can handle per day
calls_per_day_per_agent = {'perm': calls_per_hour_per_agent['perm'] * hours_per_shift,
'temp': calls_per_hour_per_agent['temp'] * hours_per_shift}


### Total Agent Mix¶

In [16]:
agents = LpVariable.dicts("Agents",labor_types, 0, None, cat=LpInteger)

# We want to minimize the Labor Cost function
model = LpProblem("Number of Agents", LpMinimize)

model += lpSum(
[agents[t] * hours_per_shift * days_per_week * cost_per_agent_hour[t]
for t in labor_types]
), "LaborCost"

# -----------------
# Constraints
# -----------------

# Must satisfy total demand
model += lpSum([agents[t] * calls_per_day_per_agent[t] * days_per_week
for t in labor_types]
) >= total_calls, 'TotalDemand'

# No more than N agents per type
for t in labor_types:
model += lpSum([agents[t]]) <= max_agents[t], 'Max_{:}'.format(t)

# Minimum of N agents per type
for t in labor_types:
model += lpSum([agents[t]]) >= min_agents[t], 'Min_{:}'.format(t)


Let's solve our model:

In [17]:
model.solve(pulp.GLPK())
print (pulp.LpStatus[model.status])

Optimal


We'll turn the model outputs into a dataframe for reporting:

In [21]:
print ('Cost:\t\t ', pulp.value(model.objective) )

sol_1 = [
(v.name.split('_')[1],
int(v.varValue)
)
for v in model.variables()
]

df_sol_1 = pd.DataFrame(sol_1, columns=['type', 'agents'])

# We'll calculate the total call capacity of our agent pool
for t in labor_types:
calls_capacity = calls_per_day_per_agent[t] * days_per_week
agent_call_capacity = df_sol_1.loc[df_sol_1['type'] == t, 'agents'] * calls_capacity
df_sol_1.loc[df_sol_1['type'] == t, 'call_capacity'] = agent_call_capacity

df_sol_1

Cost:		  25650

Out[21]:
type agents call_capacity
0 perm 14 3150.0
1 temp 29 3915.0

So, based on our first model, we'll need 14 permanent and 29 temp employees to satisfy our call demand.

### Agent Mix By Weekday¶

Our second model breaks out the labor requirements by day, given a set of % of weekly volume for each day

We start the week on Mondays and set the target %s for each weekday

In [22]:
weekdays = {1: 'M', 2: 'T', 3: 'W', 4: 'Th', 5: 'F', 6: 'S', 7: 'Su'}
weekdays

Out[22]:
{1: 'M', 2: 'T', 3: 'W', 4: 'Th', 5: 'F', 6: 'S', 7: 'Su'}
In [23]:
wdays = [i for i in range(1, len(weekdays)+1)]
weekday_perc = np.array([.20,.18,.16,.16, .12,.10,.08])
np.sum(weekday_perc)

Out[23]:
1.0
In [24]:
weekday_dist = dict(zip(weekdays, weekday_perc))
weekday_dist

Out[24]:
{1: 0.20000000000000001,
2: 0.17999999999999999,
3: 0.16,
4: 0.16,
5: 0.12,
6: 0.10000000000000001,
7: 0.080000000000000002}

Let's look at what this distribution looks like:

In [25]:
fig, ax = plt.subplots(1, 1)
ax.bar([w for w in range(1, len(weekday_dist.keys())+1)],
[v for v in weekday_dist.values()])
ax.set_ylim(0, np.round(np.max([v for v in weekday_dist.values()]), 1));

In [28]:
agents = LpVariable.dicts("Agents",(weekdays, labor_types), 0, None, cat=LpInteger)

# We want to minimize the Labor Cost function
model = LpProblem("Number of Agents by Day", LpMinimize)

model += lpSum(
[agents[d][t] * hours_per_shift * cost_per_agent_hour[t]
for d in weekdays
for t in labor_types]
), "LaborCost"

# -----------------
# Constraints
# -----------------

# Must satisfy total demand
model += lpSum(
[agents[d][t] * calls_per_day_per_agent[t]
for d in weekdays
for t in labor_types]
) >= total_calls, 'TotalDemand'

# Must satisfy daily demand
for d in weekdays:
daily_demand = np.ceil(total_calls * weekday_dist[d])
model += lpSum(
[agents[d][t] * calls_per_day_per_agent[t] for t in labor_types]
) >= daily_demand, 'MinCallsDays_{:}'.format(d)

# No more than N agents per type per weekday
for t in labor_types:
for d in weekdays:
max_agnt = 2 if d in (6, 7) and t == 'perm' else max_agents[t]
model += lpSum([agents[d][t]]) <= max_agnt, 'Max_{:}_{:}'.format(t, d)

# Minimum of N agents per type per weekday
for t in labor_types:
for d in weekdays:
# on the weekends, only 2 minimum perms
min_agnt = 2 if d in (6, 7) and t == 'perm' else min_agents[t]
model += lpSum([agents[d][t]]) >= min_agnt, 'Min_{:}_{:}'.format(t, d)

print(model)

Number of Agents by Day:
MINIMIZE
180*Agents_1_perm + 90*Agents_1_temp + 180*Agents_2_perm + 90*Agents_2_temp + 180*Agents_3_perm + 90*Agents_3_temp + 180*Agents_4_perm + 90*Agents_4_temp + 180*Agents_5_perm + 90*Agents_5_temp + 180*Agents_6_perm + 90*Agents_6_temp + 180*Agents_7_perm + 90*Agents_7_temp + 0
SUBJECT TO
TotalDemand: 45 Agents_1_perm + 27 Agents_1_temp + 45 Agents_2_perm
+ 27 Agents_2_temp + 45 Agents_3_perm + 27 Agents_3_temp + 45 Agents_4_perm
+ 27 Agents_4_temp + 45 Agents_5_perm + 27 Agents_5_temp + 45 Agents_6_perm
+ 27 Agents_6_temp + 45 Agents_7_perm + 27 Agents_7_temp >= 7000

MinCallsDays_1: 45 Agents_1_perm + 27 Agents_1_temp >= 1400

MinCallsDays_2: 45 Agents_2_perm + 27 Agents_2_temp >= 1260

MinCallsDays_3: 45 Agents_3_perm + 27 Agents_3_temp >= 1120

MinCallsDays_4: 45 Agents_4_perm + 27 Agents_4_temp >= 1120

MinCallsDays_5: 45 Agents_5_perm + 27 Agents_5_temp >= 840

MinCallsDays_6: 45 Agents_6_perm + 27 Agents_6_temp >= 700

MinCallsDays_7: 45 Agents_7_perm + 27 Agents_7_temp >= 560

Max_perm_1: Agents_1_perm <= 50

Max_perm_2: Agents_2_perm <= 50

Max_perm_3: Agents_3_perm <= 50

Max_perm_4: Agents_4_perm <= 50

Max_perm_5: Agents_5_perm <= 50

Max_perm_6: Agents_6_perm <= 2

Max_perm_7: Agents_7_perm <= 2

Max_temp_1: Agents_1_temp <= 30

Max_temp_2: Agents_2_temp <= 30

Max_temp_3: Agents_3_temp <= 30

Max_temp_4: Agents_4_temp <= 30

Max_temp_5: Agents_5_temp <= 30

Max_temp_6: Agents_6_temp <= 30

Max_temp_7: Agents_7_temp <= 30

Min_perm_1: Agents_1_perm >= 10

Min_perm_2: Agents_2_perm >= 10

Min_perm_3: Agents_3_perm >= 10

Min_perm_4: Agents_4_perm >= 10

Min_perm_5: Agents_5_perm >= 10

Min_perm_6: Agents_6_perm >= 2

Min_perm_7: Agents_7_perm >= 2

Min_temp_1: Agents_1_temp >= 0

Min_temp_2: Agents_2_temp >= 0

Min_temp_3: Agents_3_temp >= 0

Min_temp_4: Agents_4_temp >= 0

Min_temp_5: Agents_5_temp >= 0

Min_temp_6: Agents_6_temp >= 0

Min_temp_7: Agents_7_temp >= 0

VARIABLES
0 <= Agents_1_perm Integer
0 <= Agents_1_temp Integer
0 <= Agents_2_perm Integer
0 <= Agents_2_temp Integer
0 <= Agents_3_perm Integer
0 <= Agents_3_temp Integer
0 <= Agents_4_perm Integer
0 <= Agents_4_temp Integer
0 <= Agents_5_perm Integer
0 <= Agents_5_temp Integer
0 <= Agents_6_perm Integer
0 <= Agents_6_temp Integer
0 <= Agents_7_perm Integer
0 <= Agents_7_temp Integer


In [29]:
model.solve(pulp.GLPK())
print (pulp.LpStatus[model.status])

Optimal

In [31]:
print ('Cost:\t\t ', pulp.value(model.objective) )

# Extract variable values from the solution
sol = [(v.name.split('_')[1],
v.name.split('_')[2],
int(v.varValue))
for v in model.variables()
]

Cost:		  25290


We turn the solution's variable values into a dataframe to make reporting easier:

In [32]:
df_sol = pd.DataFrame(sol, columns=['weekday', 'type', 'agents'])

for t in labor_types:

calls_capacity = calls_per_day_per_agent[t]
agent_call_capacity = df_sol.loc[df_sol['type'] == t, 'agents'] * calls_capacity
df_sol.loc[df_sol['type'] == t, 'call_capacity'] = agent_call_capacity

df_sol.pivot_table('agents', 'weekday', 'type', aggfunc='sum')

Out[32]:
type perm temp
weekday
1 14 29
2 10 30
3 10 25
4 10 25
5 11 13
6 2 23
7 2 18
In [33]:
df_sol.pivot_table('call_capacity', 'weekday', 'type', aggfunc='sum')

Out[33]:
type perm temp
weekday
1 630.0 783.0
2 450.0 810.0
3 450.0 675.0
4 450.0 675.0
5 495.0 351.0
6 90.0 621.0
7 90.0 486.0
In [34]:
df_sol.groupby('weekday').sum()

Out[34]:
agents call_capacity
weekday
1 43 1413.0
2 40 1260.0
3 35 1125.0
4 35 1125.0
5 24 846.0
6 25 711.0
7 20 576.0