ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

HR数学建模实战:从排班整数规划到PuLP求解与敏感性分析

HR数学建模实战:从排班整数规划到PuLP求解与敏感性分析 简介一份关于人力资源配置数学建模的完整文档聚焦大型软件公司三类职员系统分析师、高级程序员、程序员的规划问题通过整数线性规划构建优化模型解决“减少辞退”与“降低费用”两大典型目标。文档细致梳理了招聘、培训、降等、辞退、临时工等流动方式给出变量定义、约束条件和目标函数并借助单纯形法与数学软件完成求解适合运筹学、管理科学方向的学生以及需要制定人员优化方案的企业管理人员学习参考。压缩包内仅有1个doc文档约57KB内容完整、公式清晰便于直接阅读与复用。目前已有70人学习使用。文档从问题假设到模型求解逐步展开可帮助读者掌握数学建模的完整流程并能迁移到服务业排班、生产计划等类似人力资源优化场景中。1. 人力资源问题的数学模型把排班定编从经验决策变成可求解的优化问题客服排班长、门店定编、校招名额和培训预算怎么切这类人力决策过去靠Excel加经验规模一超过几十人人工方案往往既不是最低成本也说不清每条约束为什么成立。把人力资源问题写成数学模型本质是把决策目标、硬约束和可用资源翻译成变量、目标函数与不等式交给求解器在可行域里找最优解同时留下可复核的推导链。标题末尾的 .doc 恰好点出这类任务的交付形态算出的数字只是中间产物模型假设、参数来源、求解状态必须落进文档别人才可能复现结论。下文按一线做法拆先给HR问题分类选模型再用PuLP把排班写成整数规划跑通接着做敏感性分析找边界最后收在报告验证和图表取舍上。2. 先给HR问题分类再选线性规划、整数规划还是马尔可夫链数学模型不是一套万能公式套所有HR场景。同一家公司排班问题和离职预测的数学结构完全不同用错模型比不用更糟。我在接这类需求时第一个动作是问清楚三件事决策变量是什么——人数、班次、金额还是概率约束有没有取整要求——人不能出现小数目标能不能量化——最小成本、最大覆盖率还是多目标权衡。这三个问题的答案基本决定该用哪一族模型。2.1 四类常见HR建模场景与模型选型表业务场景决策内容数学模型常用求解工具班次排布 / 值班排班各时段在岗人数、班次组合整数规划 ILP、约束规划 CPPuLP、ortools、Excel Solver定编 / 客服坐席规模各时段最少需要多少人排队论 Erlang、离散仿真simpy、呼叫中心专用计算器人员结构 / 继任规划各职级未来 1-5 年人数马尔可夫链NumPy、Vensim招聘 / 培训预算分配预算在各渠道或项目间的切分线性规划、多目标规划PuLP、LINGO选型理由要落到数学结构上定编场景的核心是到达速率 × 服务速率之间的关系排队论直接给出服务水平下的最少人数排班涉及整数个人变量带取整语义必须走整数规划而不是普通线性规划人员流动天然是状态转移过程用马尔可夫链最贴切。多目标场景既想成本低又想员工满意度高先用权重法或分层法转成单目标不要让求解器去比较两个量纲不同的目标。2.2 从业务描述到数学表达变量、目标、约束的对应关系以培训预算分配为例设 x 和 y 分别是投给项目 A、项目 B 的金额万元目标是总投入最小同时保证受训人数达标。每条约束都对应一句业务规则渠道上限对应单个供应商不超过 200 万覆盖要求对应至少 300 人次受训。用 PuLP 写最小可运行版本import pulp prob pulp.LpProblem(training_budget, pulp.LpMinimize) x pulp.LpVariable(project_a, lowBound0) # 项目A投入金额(万元) y pulp.LpVariable(project_b, lowBound0) # 项目B投入金额(万元) prob x y, 总投入 prob 0.8 * x 0.6 * y 300, 受训人次下限 prob x 200, 渠道A上限 prob y 150, 渠道B上限 prob.solve() print(pulp.LpStatus[prob.status], x.value(), y.value())逻辑说明目标函数里 x y 是总投入约束里 0.8 和 0.6 是把金额换算成受训人次的线性系数——每投入 1 万元项目 A 能带动 0.8 人次受训这个系数由人均课程成本倒推。参数 300、200、150 分别来自培训目标和采购合同是建模时最需要业务签字确认的数字。此类两变量小模型在 Excel Solver 里也能跑但后续要做批量敏感性分析时脚本化明显更顺手。2.3 用马尔可夫链快速推演职级人数的变化继任规划里经常要回答按现在的晋升和离职率三年后各职级多少人用马尔可夫链写计算只用几十行import numpy as np # 状态依次为 P1, P2, P3, 离职行今年所在职级列明年去向 T np.array([ [0.60, 0.30, 0.00, 0.10], # P1: 60%留任, 30%升P2, 10%离职 [0.00, 0.70, 0.20, 0.10], # P2: 70%留任, 20%升P3, 10%离职 [0.00, 0.00, 0.80, 0.20], # P3: 80%留任, 20%离职 [0.00, 0.00, 0.00, 1.00], # 离职为吸收态 ]) headcount np.array([50.0, 30.0, 20.0, 0.0]) for year in range(1, 4): headcount headcount T print(f第{year}年末: P1{headcount[0]:.1f} P2{headcount[1]:.1f} P3{headcount[2]:.1f} 离职{headcount[3]:.1f})逻辑说明转移矩阵每行行和为 1代表一个人今年一定对应明年某个状态离职行是纯吸收态进入后不再离开。headcount T 是向量与矩阵相乘等价于把各职级人数按概率重新分配到下一年的状态空间。参数 0.6、0.3、0.1 这类数字通常取过去 12-24 个月人事异动数据按月统计平均得到样本量不足时先按业务经验设初值再调。这个模型的产出不只是预测人数还能算出各职级年净增净减量是编制预算和招聘计划的前置输入。3. 用PuLP把排班模型写成最小整数规划跑通一次完整求解排班是HR数学模型的经典入口因为变量和约束都能被业务同事直观验证。以客服中心白班排程为例把一天切成 6 个时段每个时段有最低在岗人数要求候选班次有 7 种每种覆盖连续几个时段各有工时成本。目标是在满足每个时段覆盖的前提下让总成本最小。这个模型小到能在笔记本上跑完却已经包含整数规划的全部关键要素。3.1 需求向量与班次覆盖表先定下来建模第一步是固化输入数据而不是写代码。覆盖表是排班建模的核心班次集越丰富总成本越可能下降但求解规模随之变大。下面这张表按 2 小时为一个时段整理班次覆盖时段单班成本(单位)A 8-141,2,36B 10-162,3,46C 12-183,4,56D 14-204,5,66E 8-121,24F 12-163,44G 16-205,64需求向量按时段 1 到 6 为 [4, 6, 5, 7, 4, 3]意思是时段 414-16 点至少 7 人在岗。成本按工时折算A-D 是 6 小时班E-G 是 4 小时短班短班时薪成本低所以单班成本也低。实际业务里这张表通常由运营和排班主管一起确认包含午休错峰、兼职比例等信息建模前先让他们签字避免后面反复返工。3.2 完整PuLP代码与每行说明import pulp prob pulp.LpProblem(shift_scheduling, pulp.LpMinimize) shifts [A, B, C, D, E, F, G] x pulp.LpVariable.dicts(shift, shifts, lowBound0, catpulp.LpInteger) cost {A: 6, B: 6, C: 6, D: 6, E: 4, F: 4, G: 4} prob pulp.lpSum(cost[s] * x[s] for s in shifts), 总成本 # 每个时段的在岗人数不得低于需求 prob x[A] x[E] 4, block1 prob x[A] x[B] x[E] 6, block2 prob x[A] x[B] x[C] x[F] 5, block3 prob x[B] x[C] x[D] x[F] 7, block4 prob x[C] x[D] x[G] 4, block5 prob x[D] x[G] 3, block6 prob.solve() print(pulp.LpStatus[prob.status]) # Optimal 表示找到全局最优整数解 for s in shifts: print(s, int(x[s].value())) print(总成本, int(pulp.value(prob.objective)))逻辑说明变量 x 的下标是班次名值表示该班次排几个人catpulp.LpInteger 强制整数域这是排班模型与普通线性规划的根本区别——人不能是 2.7 个。六条约束分别对应六个时段等号左边是该时段所有在岗班次人数加总右边是需求下限。目标函数把每个班次人数乘以单班成本再求和。求解器用的是 CBC通过分支定界找到整数最优解。一组典型输出是 A1、B2、C1、D3、E3、F1、G0总成本 58同一需求下可能存在多组等价最优解这正常不必追求唯一性。需要提醒的是这个模型输出的是每种班次开几个还没有落到具体哪个人上哪个班。把班次人数拆成个人排班是第二轮问题通常要引入员工偏好、连班限制和工时平衡约束改用约束规划求解。接排班需求时第一轮先和业务说清这个边界否则他们会以为模型漏掉了个人意愿。3.3 参数怎么改需求矩阵、班次集和软约束最小模型跑通后往真实场景扩有三条常规路径。第一需求向量换粒度把 2 小时时段改成 30 分钟需求从 6 个值变成 20 个值班次覆盖表同步细化代码结构不用动。第二加每人每天最多一个班次这类全局约束写法是 pulp.lpSum(x[s] for s in shifts) total_workerstotal_workers 是可用人数上限。第三把硬约束转软约束允许某个时段缺岗但缺 1 人罚 10 个成本单位目标函数变成原成本加上 pulp.lpSum(penalty * shortage[s])。这样当需求数据有噪声时模型不会直接报不可行而是给出带缺岗警告的次优解更贴近运营实际。规模超过几百人时 CBC 可能变慢常见做法是换 ortools 的 CP-SAT 求解器模型表达方式略有差异但覆盖表和变量定义逻辑完全一致。4. 敏感性分析与模型边界排错先看影子价格和约束松弛模型解出来只是第一步真正决定交付质量的往往是这个结果稳不稳、参数拍错会怎样。敏感性分析回答两类问题某个需求约束值多少钱某个参数浮动多少会让最优解翻转。这两类问题分别对应影子价格和参数扫描排错时也最先看这两个输出。4.1 影子价格每个时段需求约束值多少钱PuLP 求解后每个约束对象上直接带着对偶信息用几行代码就能读出来for name, c in prob.constraints.items(): print(f{name}: shadow_price{c.pi:.2f}, slack{c.slack:.2f})逻辑说明shadow_price 是影子价格表示该时段需求每增加一个人总成本最少增加多少slack 是松弛量表示当前解下该约束富余了几个人。这是和业务对需求的底气所在某个时段影子价格为 0 且有正向松弛说明那里存在冗余适当下调服务水平不影响总成本影子价格较高的时段就是瓶颈需求预测在这里要格外谨慎。注意整数规划下 c.pi 是从线性松弛模型算出来的只能当方向和量级参考精确的边界要用参数扫描重新求解这正是下一章的做法。4.2 整数可行解和线性松弛差在哪把第 3 章的约束放成连续变量求解器可能给出 x_B1.6、x_F2.4 这类结果总成本会比整数解更低。此时四舍五入会出两类问题往上取整浪费预算往下取整则某个时段约束被悄悄突破。整数规划的可行域是非凸的最优解不一定在可行域的角落附近所以先解线性规划再取整只能当启发式不能当严谨方案。CBC 内部走分支定界返回 Optimal 说明上下界已经闭合整数最优性成立如果状态是 Infeasible优先检查约束之间是否打架比如同时要求总人数不超过 10和每个时段至少 6 人在 6 时段场景下就无解。4.3 常见误用和模型边界误用现象修正用平均需求当每个时段需求高峰缺人、低谷冗余按时段或分情景拆需求变量漏写整数约束结果出现 2.7 人检查 catInteger硬约束堆叠过头求解状态 Infeasible逐条删约束定位冲突或转软约束忽略员工偏好排班可行但留存率崩加偏好权重转目标规划模型边界在这里要写清楚数学规划负责在给定需求下做优化不负责预测需求本身。需求的月度波动、活动大促带来的尖峰应该在进模型之前就处理成多情景需求向量而不是指望约束条件自己吸收误差。这个边界写进 .doc 报告后评审才不会在需求预测不准时把责任算在模型头上。5. 模型写进 .doc 报告三步验证、参数扫描和最终图的取舍把模型结论写进文档时最有说服力的不是那一行最优值而是验证过程和参数稳定性证据。收稿前先做三步验证再决定图表放什么。5.1 收稿前先做三步验证第一步回代验证把求解出的班次人数代回六条约束循环检查是否全部满足防止脚本里约束漏写导致求解成功但业务不可行。第二步与现状对比拿模型结果和当前人工排班的人数、总成本做差定位差异集中在哪几个时段这段 delta 分析是业务方最关心的内容直接决定他们信不信模型。第三步扰动验证对需求上下浮动做参数扫描观察最优解是否平滑变化如果某个时段需求只加 1 成本就跳变一大截说明系统接近瓶颈要在报告里显式标注。5.2 优化模型和数学模型的最终图哪些必须有优化模型数学模型应该有最终图吗这类问题的答案是要有但只放两类。必须有的是覆盖表与最优解的对照表以及需求-成本敏感性折线收敛曲线、三维曲面、复杂网络图在精确求解器场景下属于装饰可以不放。图存在的唯一目的是让评审在 30 秒内看懂结论怎样随参数变化用 matplotlib 画一条折线已经足够。参数扫描的核心代码def run(demand_b4): prob pulp.LpProblem(shift_sweep, pulp.LpMinimize) x pulp.LpVariable.dicts(shift, shifts, lowBound0, catpulp.LpInteger) prob pulp.lpSum(cost[s] * x[s] for s in shifts) prob x[A] x[E] 4 prob x[A] x[B] x[E] 6 prob x[A] x[B] x[C] x[F] 5 prob x[B] x[C] x[D] x[F] demand_b4 prob x[C] x[D] x[G] 4 prob x[D] x[G] 3 prob.solve() if pulp.LpStatus[prob.status] Optimal: return int(pulp.value(prob.objective)), [int(x[s].value()) for s in shifts] return None, None for d in range(5, 10): obj, sol run(d) print(fblock4_demand{d} cost{obj} solution{sol})逻辑说明run 函数每次重新构建模型只改 block4 的需求值保持其余输入不变这样扫描结果才能干净归因。从输出能看到第 4 时段需求从 7 涨到 8 时总成本从 58 涨到 62增长落在 D 和 F 班次的增量上。把这张参数扫描表连同覆盖率核对表一起放进 .doc 的附录评审在评审会上问需求变了怎么办时直接翻到这一页就能对答。本文还有配套的精品资源点击获取
返回列表