This notebook covers the set cover and covering problems, and their main practical use, shift scheduling: choosing the cheapest set of shifts (or facilities) that between them cover every point that needs covering (an hour, a location, ...). Unlike the transportation problem, integrality is essential here. Both problems are introduced in the same steps as the transportation problem: practical motivation, generic model, the model for an example, its solution in pulp, and extensions.
Learning outcomes
On completion of this notebook, you will be able to:
formulate set cover and covering problems as ILO models;
explain why the integrality constraints cannot be dropped in a set cover problem;
formulate shift-scheduling problems as covering problems and solve them with pulp.
The Set Cover Problem¶
Practical Motivation¶
An ambulance service wants to open as few ambulance bases as possible, while every address in its region can still be reached from some base within the target response time (in the Netherlands, 15 minutes). For every candidate location we know which addresses it can reach in time; the question is which candidates to open. Fire stations, police posts and parcel lockers lead to the same question. A less obvious application comes from IBM, which used it for efficient virus scanning: instead of scanning for every known virus signature, scan for a small collection of code fragments that between them occur in all known viruses.
Modeling¶
Problem Definition and Example¶
We have a universe of elements that must be covered, and sets with . The problem is to find the smallest collection of these sets whose union is all of . In the ambulance application, is the set of addresses and the set of addresses that candidate base reaches in time.
As an example, a region has six incident locations, , and six candidate bases B1, ..., B6, which reach the following locations in time:
| base | B1 | B2 | B3 | B4 | B5 | B6 |
|---|---|---|---|---|---|---|
| locations reached | 1, 2, 3 | 2, 3, 4 | 4, 5 | 5, 6 | 1, 6 | 3, 4, 5 |
Decision Variables¶
For every set we decide whether to take it or not, which is a yes/no decision. We model it with a binary decision variable per set:
Which set contains which element is data, not a decision. For the constraints it is handy to turn this data around: for every element , let
be the index set of the sets that contain . In the example, location 1 is reached by B1 and B5, so . An index set derived from the data like this is a common way to sum over only the terms that matter.
Objective¶
We minimize the number of sets taken, .
Constraints¶
Every element must be in at least one of the sets taken. The number of sets taken that contain is , since only the sets in contain . So, for every ,
We use “” and not “”: an element may well be covered twice (two bases that both reach an address). Requiring exactly one would be a different, much more restrictive problem that often has no feasible solution at all. Finally, .
Complete ILO Model¶
Modeling the Example¶
With for bases B1, ..., B6, each location gives one constraint, with the bases that reach it:
Solving the Example in pulp¶
The binary variables (cat="Binary") are similarly setup as in the knapsack
problem. New is the derived index set: the data
is stored as the sets themselves (a Python set per base), and the sum
becomes a sum over only those bases whose set contains ,
written with an if inside the generator. We do not necessarily have to build separately.
import pulp
covers = {
"B1": {1, 2, 3},
"B2": {2, 3, 4},
"B3": {4, 5},
"B4": {5, 6},
"B5": {1, 6},
"B6": {3, 4, 5},
}
universe = sorted(set().union(*covers.values()))
set_cover = pulp.LpProblem(name="set_cover", sense=pulp.LpMinimize)
pick = {s: pulp.LpVariable(name=s, cat="Binary") for s in covers}
set_cover += pulp.lpSum(pick.values())
for u in universe:
n_covering = pulp.lpSum(pick[s] for s in covers if u in covers[s])
set_cover += n_covering >= 1, f"cover_{u}"
set_cover.solve(pulp.PULP_CBC_CMD(msg=False))
print("status:", pulp.LpStatus[set_cover.status])
print("bases:", [s for s in covers if pick[s].value() == 1])status: Optimal
bases: ['B2', 'B4', 'B5']
Three bases suffice. Two cannot: location 6 is only reached by B4 and B5. With B4, a second base would have to reach locations 1 to 4, and with B5 it would have to reach locations 2 to 5, and no base does either.
Extensions¶
Are the Integrality Constraints Needed?¶
In the transportation problem, we could drop integrality and still get whole numbers. Here we cannot. Take the small instance with , , . No single set covers , so the integer optimum is 2 (any two sets). But the LO relaxation, with instead of , can set every : each element is then covered by , at total “cost” 1.5. Adding the three constraints gives , so the relaxation can never beat 1.5, and rounding the 0.5’s up gives all three sets, which is worse than the true optimum of 2.
The Covering Problem¶
The covering problem generalizes set cover in three ways: each element must be covered times instead of once, a set may be chosen more than once (), and each set has a cost :
With and binary, it is a set cover problem in which bases have different costs. Its main application, shift scheduling, is the next problem.
Shift Scheduling¶
Practical Motivation¶
A shop, a call center or a hospital ward needs a certain number of staff at work in every hour of the day, and that number varies over the day. Staff are hired for shifts of fixed types (a morning shift, a full-day shift, ...), each with its own cost. How many workers should be scheduled on each shift type, so that every hour has enough staff at the lowest total cost? The required staffing per hour typically comes from a demand forecast (predictive analytics). Dantzig studied this problem in 1954 for the staffing of toll booths.
Modeling¶
Problem Definition and Example¶
Shift scheduling is a covering problem. Split the day into time intervals , and let be the required staffing in interval . Each of the shift types works a set of intervals and costs per worker. As before, is the index set of the shift types that work in interval . The problem is to find the cheapest number of workers per shift type such that every interval has at least its required staffing.
As an example, a small shop is open from 8:00 to 12:00 (four one-hour intervals) and has four shift types:
| shift type | morning | midday | late | full |
|---|---|---|---|---|
| works | 8-10 | 9-11 | 10-12 | 8-12 |
| intervals | 1, 2 | 2, 3 | 3, 4 | 1, 2, 3, 4 |
| cost (€) | 30 | 30 | 30 | 50 |
The required staffing is for the intervals 8-9, 9-10, 10-11 and 11-12.
Decision Variables¶
Several workers can work the same shift type, so taking a set is no longer a yes/no decision but a number:
with , since we cannot schedule half a worker.
Objective¶
We minimize the total staff cost, .
Constraints¶
The number of workers present in interval is the sum of the workers on all shift types in , which must be at least the requirement:
Overstaffing is allowed (“”): with fixed shift lengths it is often unavoidable, since a worker needed in one interval may also be present in a quieter one.
Complete ILO Model¶
This is exactly the covering problem from the previous section.
Solving the Example in pulp¶
The code has the same shape as the set cover code. The only change in the variables
is cat="Integer" with lowBound=0, which gives , and
the constraints now have the right-hand side need instead of 1.
shift_intervals = {
"morning": {1, 2},
"midday": {2, 3},
"late": {3, 4},
"full": {1, 2, 3, 4},
}
shift_cost = {"morning": 30, "midday": 30, "late": 30, "full": 50}
required = {1: 3, 2: 6, 3: 7, 4: 4}
shifts = list(shift_intervals)
roster = pulp.LpProblem(name="shift_scheduling", sense=pulp.LpMinimize)
count = {s: pulp.LpVariable(name=s, lowBound=0, cat="Integer") for s in shifts}
roster += pulp.lpSum(shift_cost[s] * count[s] for s in shifts)
for u, need in required.items():
staffed = pulp.lpSum(count[s] for s in shifts if u in shift_intervals[s])
roster += staffed >= need, f"staff_{u}"
roster.solve(pulp.PULP_CBC_CMD(msg=False))
print("status:", pulp.LpStatus[roster.status])
print("roster:", {s: count[s].value() for s in shifts})
print("total cost:", roster.objective.value())status: Optimal
roster: {'morning': 0.0, 'midday': 3.0, 'late': 1.0, 'full': 3.0}
total cost: 270.0
The optimal roster has 3 midday, 1 late and 3 full shifts, at a cost of €270. Every requirement is met exactly, so here there is no overstaffing.
Extensions¶
More Realistic Shifts¶
Real instances have many more intervals (for example, half hours over a whole day) and many more shift types, with different lengths, start times and breaks.
Not Meeting Every Requirement¶
Requiring staff in every interval can be expensive when a single peak interval forces extra shifts. An alternative is to allow under- and overstaffing and penalize both in the objective. This needs the modeling trick for absolute values from Robust Regression.
Interactions Between Intervals¶
Here the required staffing per interval is given. In a call center, understaffing in one interval leaves a queue of waiting callers for the next one, so the requirements depend on the schedule itself. Then only simulation can evaluate a schedule; see Local Search.
References¶
Koole, G. (2019). An Introduction to Business Analytics. §6.5 “Example ILO Problems” (covering). Spreadsheet material replaced with pulp.