前段时间讲课时发现简单的优化问题求解还是有些业务需求的,但是无论使用商用还是开源求解器,手工建模仍然需要一定工作量。所以这几天写了个基于pyomo和开源求解器glpk的小排程程序,可以实现多人、自定义班次和排程规则的最优化班次排程(shift assignment),优化目标包含最低成本和员工最大偏好,并且支持自定义的目标权重。程序支持excel或txt表格形式的标准化输入和输出。
业务需求示例
业务部门要求将n位员工对应至m种班次,在满足每周需求,并尽量满足员工工作时间偏好的同时使人力成本最小。排程单位为小时,planning horizon为一周(168小时)。
业务部门有预先定义的每日班次记录在表格shift里,每个班次预设时长在8-12小时之间,但理论上可以用小于24小时的任何班次设置,也可以跨不同天。
业务部门有n名员工,每位员工每周的工作时间必须满足最小工时/班次数和最大工时/班次数的要求。每位员工的时薪和输出都不同。另外每位员工可以提出对一周内所有班次的偏好,记录在表格preference里。preference为0的班次原则上不安排给员工。每位员工一周内每个小时的availability另外记录在表格availability里。这张表的值为0的时间段也不会安排员工工作(比如休假)。
业务部门定义了每小时的人力需求记录在表格demand里。但每小时的需求不需要严格满足;最终只要求排程满足每周的总体需求,而每小时的需求只需要满足minDemand和maxDemand区间:这是为了保证每小时capacity的上下限。
表格regulation记录所有其他关于班次的要求,目前包含:
- 每人每周最大/最小工作小时数/班次数
- 每人的相邻两班次最少相隔小时数
- 每周最少相同相邻班次个数(保证工作时段稳定性)
- 如果有夜班,每周最少和最多相邻夜班个数(保证工作时段稳定性)
- 属于夜班班次的时间范围
- 基础时薪,以及夜班、周末、法定节假日加班薪酬比例
此外还有关于多目标权重的设定:
- 排程考虑员工偏好的权重
- 排程考虑运营成本的权重
输入格式
Demand:
字段 | 示例 | 描述 |
---|---|---|
publicHoliday | 0 | 1为公共假日,0为非公共假日 |
weeks | 1 | 周index,默认只有一周排程 |
days | 1 | 1-7,周一为1,周日为7 |
hours | 1 | 从1开始递增,表示排程中的第n小时 |
hourPerDay | 1 | 1-24,1代表第0-1小时,24代表第23-24小时 |
demand | 2 | 绝对需求,以小时为单位 |
min | 1 | 最小capacity |
max | 4 | 最大capacity |
排程时考虑每周完成绝对demand的总量,每小时保证在最低和最高capacity之间。
Shift:
字段 | 示例 | 描述 |
---|---|---|
weeks | 1 | 周index,默认只有一周排程 |
days | 1 | 1-7,周一为1,周日为7 |
hours | 1 | 从1开始递增,表示排程中的第n小时 |
hourPerDay | 1 | 1-24,1代表第0-1小时,24代表第23-24小时 |
shiftA | 1 | ID为shiftA的占用时间,1为占用,0为不占用 |
shiftB | 0 | ID为shiftB的占用时间,1为占用,0为不占用 |
只包含1天内的班次,默认一周内每天的可能班次相同。在排程时会考虑跨半夜和跨周的班次连接。另外根据demand,每个班次可能排多人,也可能不会使用。
24小时shift示例:
weeks | days | hours | hourPerDay | shiftA | shiftB | shiftC | shiftD | shiftE | shiftF | shiftG | shiftH | shiftI | shiftJ | shiftK |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 1 | 1 | 1 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
1 | 1 | 2 | 2 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
1 | 1 | 3 | 3 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
1 | 1 | 4 | 4 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
1 | 1 | 5 | 5 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
1 | 1 | 6 | 6 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
1 | 1 | 7 | 7 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
1 | 1 | 8 | 8 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
1 | 1 | 9 | 9 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
1 | 1 | 10 | 10 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
1 | 1 | 11 | 11 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
1 | 1 | 12 | 12 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
1 | 1 | 13 | 13 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 0 |
1 | 1 | 14 | 14 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 0 |
1 | 1 | 15 | 15 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 1 | 0 | 1 |
1 | 1 | 16 | 16 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 1 | 0 | 1 |
1 | 1 | 17 | 17 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | 1 |
1 | 1 | 18 | 18 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | 1 |
1 | 1 | 19 | 19 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 |
1 | 1 | 20 | 20 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 |
1 | 1 | 21 | 21 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 |
1 | 1 | 22 | 22 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 |
1 | 1 | 23 | 23 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 | 0 |
1 | 1 | 24 | 24 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 | 0 |
Availability:
字段 | 示例 | 描述 |
---|---|---|
publicHoliday | 0 | 1为公共假日,0为非公共假日 |
weeks | 1 | 周index,默认只有一周排程 |
days | 1 | 1-7,周一为1,周日为7 |
hours | 1 | 从1开始递增,表示排程中的第n小时 |
hourPerDay | 1 | 1-24,1代表第0-1小时,24代表第23-24小时 |
A | 1 | ID为A的员工的availability,1代表available,0代表休假 |
B | 1 | ID为B的员工的availability,1代表available,0代表休假 |
Preference 员工期望排程:
员工ID必须对应availability的所有员工。
班次ID必须对应一周内的所有班次,数量为shift表格里班次数量 * 排程天数。
Preference数值越大代表偏好越明显。如果preference设置为0,则排程时视为不可安排。
字段 | 示例 | 描述 |
---|---|---|
employees | A | 员工ID |
employeeHourlyWage | 1 | 员工时薪 |
employeeHourlyOutput | 1 | 员工效率 |
employeePriority | 7 | 员工排程优先级,越高越优先满足preference |
shiftA1 | 1 | 员工对周一班次shiftA的preference,数值越高preference越高。默认取值范围0-3,但没有特殊要求 |
shiftB1 | 3 | 员工对周一班次shiftB的preference |
shiftA2 | 1 | 员工对周二班次shiftA的preference |
shiftB2 | 3 | 员工对周二班次shiftB的preference |
Regulation 排程规则:
规则名称 | 示例 | 单位 | 描述 |
---|---|---|---|
minWorkHour | 35 | hours | 每周每人最少工作小时数(如果有假期会自动排除) |
maxWorkHour | 60 | hours | 每周每人最大工作小时数 |
minShiftsPerWeek | 3 | shifts | 每周每人最少工作班次数(如果有假期会自动排除) |
maxShiftsPerWeek | 5 | shifts | 每周每人最大工作班次数 |
minHourBetweenShift | 12 | hours | 每人的相邻两班次最少相隔小时数 |
minShiftContinuous | 2 | days | 每周最少相同相邻班次个数(保证工作时段稳定性) |
minNightShiftContinuous | 3 | days | 如果有夜班,每周最少相邻夜班个数(保证工作时段稳定性) |
nightShiftDefinitionStart | 24 | hour | 属于夜班班次的时间范围(起始时间,不包含)。班次中有任何时间段落入范围,整个班次为夜班 |
nightShiftDefinitionEnd | 4 | hour | 属于夜班班次的时间范围(结束时间,包含) |
maxNightShiftContinuous | 5 | days | 如果有夜班,每周最多相邻夜班个数 |
standardShiftCostPerPersonPerHour | 1 | unit | 基础时薪(以1为单位,1代表100%) |
standardShiftPaymentStart | 0 | hour | 基础时薪的时间范围(起始时间,包含) |
standardShiftPaymentEnd | 24 | hour | 基础时薪的时间范围(结束时间,包含) |
additionalNightShiftCost | 1 | unit | 夜班加薪(基础时薪的百分比) |
additionalNightShiftPaymentStart | 22 | hour | 夜班加薪的时间范围(起始时间,不包含),可以不同于夜班班次时间范围,默认大于 |
additionalNightShiftPaymentEnd | 6 | hour | 夜班加薪的时间范围(结束时间,包含) |
additionalWeekendShiftCost | 1 | unit | 周末加薪(基础时薪的百分比) |
additionalWeekendDayStart | 6 | Saturday | 周末时间范围(起始日,包含) |
additionalWeekendDayEnd | 7 | Sunday | 周末时间范围(结束日,包含) |
additionalPublicHolidayCost | 1 | unit | 公共假日加薪(基础时薪的百分比) |
weightPreference | 0.1 | unit | 计算排程目标时,考虑员工preference的权重,取值在0-1之间 |
weightCost | 0.9 | unit | 计算排程目标时,考虑运营成本的权重,取值在0-1之间 |
数据检查、优化目标和约束
-
检查 【min(员工available时间,规定最长工作时间) * 员工单位产出】是否大于等于【客户需求demand总量】。如果最大产出都不能满足需求总量,在进行优化排程前会调整demand到产出的上线。
-
排程优化会使用以下硬性约束(i.e.如果不能满足,就没有优化结果):
- 每周最大/最小工作小时数。如果员工有安排假期,则相应向下调整最小工作小时数
- 每周最大/最小班次数。如果员工有安排假期,则相应向下调整最小班次数
- 相邻班次间最小间隔小时数
- 每周最多连续夜班数
- 员工availability设为0,和员工preference设为0的位置不可以安排班次
- 每周总产出必须大于等于总需求
- 每小时的总产出比如在同一时间的最大和最小需求限制以内(比如如果有产能限制)
-
另外会考虑逻辑或的约束:
- 每周最少连续相同/相似班次数。或员工完全不安排班次,就视为合理
- 每周最少连续相同/相似夜班次数。或员工完全不安排夜班,就视为合理
注意:非凸优化问题可能产生局部最优解;尤其是逻辑或约束个数很多时,结果可能只部分满足约束条件。
-
排程优化目标:
- 最小化运营成本(根据标准时薪、夜班、周末、公共节假日的设定,计算不同时间段的成本)
- 最大化员工期望的班次数
两个目标的平衡是通过设定weightPreference 和 weightCost两个参数实现的。在数据处理过程中会对所有目标相关的数据做归一化处理。
如果设置最小班次数和最小工作小时数同时为0,在满足所有硬性约束的条件下,有可能有员工没有被安排任何班次(根据成本优先安排已经有工时的员工)。
如果有要求所有员工都必须满足最小班次数和最小工作小时数,可以通过设置最小班次数和最小工作小时数中的一项。
其他数据处理
-
shift表格只包含24小时的班次信息。会将24小时的班次数据扩展到7天(168小时)。对于跨0时的班次,程序会自动做班次拼接的处理。
-
计算互斥班次:程序会计算所有不满足班次最小间隔的互斥班次。在计算过程中会自动考虑跨越周日和周一的情况(i.e. 默认所有周都是一周的重复循环)。
-
计算最大连续夜班班次个数:会生成所有的连续夜班组合,这些组合都比规定的最大连续夜班个数多1个班次。在排程过程中,员工的排程中间结果会与这些夜班组合逐一比较,并保证不出现与任一夜班组合重合的情况。
-
计算最小连续相同班次个数:会生成所有的连续相同/相似班次组合,这些组合都恰好包含规定的最小连续班次个数。在排程过程中,员工的排程中间结果会与这些连续相同班次组合逐一比较,并保证至少与其中一个组合重合。
对于相似班次的处理:入参中可以设置considerSimilarShifts=True, 同时设置“相似”班次的标准:similarShiftHours有多少个小时重合的情况下视为相似班次。
-
计算最小连续相同夜班个数:同4.
建模简述
除了需要逻辑或(Logical-OR)的约束,其他约束和目标都满足线性条件,可以使用标准线性建模方法。
需要逻辑或的约束条件是通过convex hull实现的。建模使用了pyomo中的Generalized Disjunctive Programming (GDP),具体参考这里。
由于逻辑或是non-convex约束,大量的逻辑或会增加模型的计算量,而且并不保证能够找到可行解。在建模时把逻辑或约束放在其他约束之前,可以在一定程度上提高解的质量。
另外通过createCombinations函数会生成hourlyOutput的不同组合作为模型的超参数输入。不同组合会生成不同的解。程序会根据逻辑或约束的满足程度对解排序,在一定程度上提高解的质量。程序的执行效率通过进程池加速。
hourlyOutput组合是所有满足hourlyOutput小于等于给定output的值的组合。所以给定各个员工的hourlyOutput越高或员工数量越大,组合数量就越大,遍历所有组合需要的时间也越长。之所以选择hourlyOutput作为可变化的超参数,是因为默认所有小于给定hourlyOutput的值也都属于合理范围,等价于牺牲成本换取对约束的满足。
如果仍然出现结果不满足逻辑或约束的情况,可以尝试放宽demand的上下限。
详细建模说明:将业务需求转化为目标和约束
决策变量:
设优化问题的决策变量为 x,这个矩阵的维度是【员工人数n * 班次个数m】,取值为0或1。
优化求解的目的是找到一组x,使目标函数(偏好-成本)达到最大值。
约束:
1… 最大maxshift和最小班次数minshift的约束可以表示为:
∑ j m x i j > = m i n s h i f t , i ∈ 1 , 2 , ⋯   , n \sum_j^m x_{ij} >= minshift, i \in {1,2, \cdots, n} j∑mxij>=minshift,i∈1,2,⋯,n
∑ j m x i j < = m a x s h i f t , i ∈ 1 , 2 , ⋯   , n \sum_j^m x_{ij} <= maxshift, i \in {1,2, \cdots, n} j∑mxij<=maxshift,i∈1,2,⋯,n
2… 给定每个班次所在的时间段为向量:
s h i f t j , j ∈ 1 , 2 , ⋯   , m shift_j, j \in {1,2, \cdots, m} shiftj,j∈1,2,⋯,m
每个向量的长度为 t = 7*24 = 168。
则有每位员工对应的工作时间为:
o n D u t y i k = ∑ j m x i j ∗ s h i f t j k , i ∈ 1 , 2 , ⋯   , n , k ∈ 1 , 2 , ⋯   , t onDuty_{ik} = \sum_j^m x_{ij} * shift_{jk}, i \in {1,2, \cdots, n}, k \in {1,2, \cdots, t} onDutyik=j∑mxij∗shiftjk,i∈1,2,⋯,n,k∈1,2,⋯,t
onDuty的取值应为0或1。
3… 最大maxhour工作时长和最小工作时长minhour的约束可以表示为:
∑ k t o n D u t y i k > = m i n h o u r , i ∈ 1 , 2 , ⋯   , n \sum_k^t onDuty_{ik} >= minhour, i \in {1,2, \cdots, n} k∑tonDutyik>=minhour,i∈1,2,⋯,n
∑ k t o n D u t y i k < = m a x h o u r , i ∈ 1 , 2 , ⋯   , n \sum_k^t onDuty_{ik} <= maxhour, i \in {1,2, \cdots, n} k∑tonDuty