我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

资源受限施工项目多目标调度优化:算法与Python实战

资源受限施工项目多目标调度优化:算法与Python实战 资源受限施工项目多目标调度优化这个题目一听就知道是冲着实际工地上的资源焦虑来的。材料不够、机械排不开、工人班组搭不上再加上业主和项目经理两头催工期、压成本随便哪个项目都能列出一堆让人挠头的问题。我去年在几个市政和房建项目上试过几套调度方案最后发现真正能落地的还是把问题拆成多目标来优化而不是一拍脑袋按经验排。这篇文章就把我踩过的坑、试过的方法、以及一套可以跑的Python代码都拿出来聊一聊给正在被资源调度折磨的同行一点参考。先说清楚这个东西是什么、能干什么。资源受限施工项目多目标调度优化简单说就是在一个项目里同时把工期、成本、资源均衡这些目标放在一个优化框架里求一个折中解。它解决的问题是当你的资源有限不可能无限投入时怎么安排工序顺序和资源分配让工期尽量短、成本尽量低、资源波动尽量小。适合谁看如果你是施工项目经理、项目计划员或者正在做施工信息化的数据工程师这篇文章能告诉你一种把“经验排计划”变成“算法排计划”的具体路径而且代码可以直接改着用。1 项目背景与问题定义1.1 资源受限施工调度的核心痛点很多人一提调度就想着用甘特图把工序排清楚但实际施工里真正的麻烦不是“排顺序”而是“抢资源”。你三台塔吊要供五栋楼的混凝土浇筑电工班组只有六个人却要同时满足电气预埋和桥架安装材料堆场就那么点地方钢筋和模板不可能同时堆一场。这种时候单一目标排序根本不起作用因为资源瓶颈会随时打断你的计划。另一个痛点是目标之间本来就打架。业主想压缩工期你就得加人加机械成本马上上去你想省成本就要拉长一些非关键工作的周期资源使用倒是均衡了但工期又紧张起来。这种多目标之间的矛盾决定了你不能靠“拍脑门”定一个权重然后用单一目标优化去硬算。你需要的是一个能输出一组折中解的算法让决策者根据现场实际情况去挑。和传统的单目标项目调度比如只求最短工期相比多目标优化的输出不是“一个最优解”而是一组互不支配的解——专业叫Pareto前沿。这个概念刚接触的人容易迷糊我打个比方你想买车A车便宜但油耗高B车贵但省油只要在“价格”和“油耗”两个目标上互有胜负这两台车就都在Pareto前沿上。调度里的工期和成本就是这样一组互相牵制的目标。施工管理里最值钱的不是找到一个玄学最优解而是从这组折中解里结合现场的天气、劳务队状态、材料到场时间选一个“当下最合适”的方案。1.2 多目标的数学建模要在代码里做优化第一步就是把调度问题变成数学模型。常见的建模方式是把每个工序看作一个活动它有持续时间、资源需求量、紧前紧后关系然后在满足约束的前提下去优化多个目标函数。我用过的目标函数主要有这几个工期目标最小化项目总工期即最后一个工序的完成时间。这个最简单就是常规的makespan。成本目标最小化总成本包括直接成本人工、机械、材料按使用时间计算和间接成本管理费、租赁费按工期计算。工期一压缩直接成本通常上升但间接成本下降所以成本曲线往往是先降后升。资源均衡目标最小化资源使用量的波动比如每天各类资源的用量与平均用量的方差之和。这个目标一般用标准差或方差来表示值越小说明资源调配越平稳对现场组织和供应商供货都友好。一个标准的数学表达大概是这样的[ \min f_1 C_{max} \quad (工期) ] [ \min f_2 \sum (c_{ij} \cdot d_{ij}) \lambda \cdot C_{max} \quad (成本) ] [ \min f_3 \sum_{k1}^{K} \sqrt{\frac{\sum_{t1}^{T}(R_k(t) - \bar{R}_k)^2}{T}} \quad (资源均衡) ]约束条件包括每个工序必须在其紧前工序完成后才能开始任何时候使用的各类资源数量不能超过可用上限工序一旦开始不能中断非抢占式等。这里有个容易踩的坑如果你用工期目标去约束成本目标会发现成本目标和工期目标高度相关算法很容易让两者同时往一个方向走导致Pareto前沿退化。我试过在目标函数里给成本目标加上一个随机扰动或者把资源均衡单独作为第三个目标这样前沿的分布会更均匀决策的时候也更好看。2 多目标优化算法选择与原理2.1 为什么用进化算法多目标调度问题属于NP-hard意思是工序一多穷举根本算不完。比如一个有30个工序的流水型调度排列组合数量是天文数字。传统的数学规划方法在很多约束耦合下容易卡死而进化算法天然适合处理这种问题——它不需要对目标函数求导也不要求目标函数是连续可微的只要你能算出“好坏”就能拿去做适应度评价。施工调度里最常用的是遗传算法。它模拟自然选择的过程把一组合法的调度方案当作“种群”每个方案中的工序顺序和资源分配当作“基因”然后通过选择、交叉、变异这些操作一代一代地进化。因为是多目标所以选择的策略不是“谁最优留谁”而是用非支配排序——把互不支配的解放在同一层优先保留前层的解这样最后就能得到一个分布均匀的Pareto前沿而不是孤零零一个点。我自己的体会是进化算法还有一个额外的好处可以随意塞进施工规则里的各种“奇怪约束”。比如某道工序只能在某个分包队进场后才能开始某些工序的持续时间会随着天气条件动态变化。这些逻辑如果写在数学规划里处理起来非常头疼但在遗传算法的代码里你只需在生成新解和评价适应度时多写几个if判断就行施工一线的灵活性能保留很多。2.2 遗传算法与粒子群的对比做多目标优化时经常有人问为什么不用粒子群PSO。粒子群在多目标连续优化问题上确实表现不错收敛速度快但它在离散的工序排序问题上并不占优势。施工调度里的决策变量很多是工序顺序、资源组合这些是典型离散变量粒子群的速度和位置更新公式很难直接映射到排序上做出来的效果很别扭。反而是遗传算法里对离散序列的操作方式很成熟交叉可以模拟PBX基于位置的交叉或者顺序交叉变异可以模拟插入式变异或者交换式变异都是专门为排列问题设计的。另一个更省事的方案是直接用现成的进化框架比如Python里的DEAP它内置了多目标优化的完整模块不用自己重复造轮子。我用DEAP跑一个20个工序的调度问题在普通笔记本电脑上500代进化大概是三到五分钟这个速度在项目前期的方案比选阶段完全够用。还有一个很实用的组合把遗传算法和一个局部的启发式规则搭配使用。比如在评价每个解的适应度时先用“最早开始时间规则”初步生成一个紧凑调度再做局部微调。这样既能保持进化算法的全局搜索能力又能让每个解本身是可行且较优的避免大量无效搜索。这点后面讲代码的时候会再展开。3 代码实现附完整示例3.1 环境准备与数据构造我用的环境是Python 3.9需要安装的依赖包有pip install deap numpy matplotlibDEAP是进化计算框架numpy用来处理矩阵运算matplotlib用来可视化Pareto前沿。数据这块我建议先自己构造一个小规模案例方便调试。下面这个案例里有12道工序2种资源比如土建和安装两类班组每道工序有持续时间、资源需求量和紧前约束。import numpy as np # 工序信息编号, 持续时间(天), 资源需求量(人), 紧前工序列表 jobs [ {id: 0, dur: 3, req: [3, 0], pre: []}, {id: 1, dur: 5, req: [2, 1], pre: [0]}, {id: 2, dur: 4, req: [1, 2], pre: [0]}, {id: 3, dur: 2, req: [0, 3], pre: [1, 2]}, {id: 4, dur: 6, req: [4, 0], pre: [1]}, {id: 5, dur: 3, req: [2, 2], pre: [3, 4]}, {id: 6, dur: 4, req: [0, 1], pre: [3]}, {id: 7, dur: 2, req: [1, 1], pre: [5]}, {id: 8, dur: 5, req: [3, 0], pre: [5, 6]}, {id: 9, dur: 3, req: [2, 1], pre: [7]}, {id: 10, dur: 4, req: [1, 2], pre: [8, 9]}, {id: 11, dur: 2, req: [0, 2], pre: [10]}, ] # 资源上限(两种资源每天可用数量) resource_cap [6, 5]构造数据时要注意提前把紧前关系检查一遍不能出现循环依赖否则后面解码工序顺序的时候会陷入死循环。这里我故意设置资源容量很小[6, 5]的资源上限意味着每次最多六个土建和五个安装工人同时在场逼着调度算法在资源冲突时去做取舍。3.2 算法核心代码与注释我先说一下整体思路。遗传算法里一个个体的“基因”是一串工序顺序但这个顺序必须满足紧前关系。怎么保证这一点我用了一个经典方法随机生成初始顺序时从所有可开工的工序中随机挑一个把它加入序列然后更新剩余工序的紧前关系一旦某道工序的所有紧前工序都已经被挑走它就变成可开工工序。这一步直接保证了初始种群的合法性。交叉和变异也都在这条合法的工序顺序上进行。交叉我用的是“顺序交叉OX”选两个父代随机取一段子序列先保留下来再从另一个父代中按顺序补充漏掉的工序。变异我用的是“插入变异”随机抽一个工序把它移到另一个合法位置前提是插入后不破坏紧前关系。实际跑下来这套组合对施工调度这类偏工序顺序的问题非常稳。下面是主体代码import random from deap import base, creator, tools, algorithms import matplotlib.pyplot as plt # 解码把个体(工序顺序)翻译成每个工序的开始时间 def decode(sequence): n len(jobs) start_time [0] * n finish_time [0] * n resource_usage [] for job_id in sequence: job jobs[job_id] earliest_start 0 for pred in job[pre]: earliest_start max(earliest_start, finish_time[pred]) # 从earliest_start开始找第一个满足资源约束的时间段 start earliest_start duration job[dur] req np.array(job[req]) # 这里用一个简单的时间扫描逐天检查资源余量 while True: end start duration # 检查[start, end)期间资源是否够用 need_max req ok True # 模拟每天的资源消耗 for t in range(start, end): # 计算当天已用资源 used np.zeros_like(req) for other in range(n): if other job_id: continue if start_time[other] is not None and start_time[other] t finish_time[other]: used np.array(jobs[other][req]) if np.any(used need_max resource_cap): ok False break if ok: break start 1 start_time[job_id] start finish_time[job_id] start duration resource_usage.append((start, finish_time[job_id], req)) return start_time, finish_time # 定义三个目标函数 def evaluate(individual): sequence list(individual) start_time, finish_time decode(sequence) makespan max(finish_time) # 成本目标简单起见用总工时工期惩罚 total_work sum(job[dur] * sum(job[req]) for job in jobs) cost total_work * 50 makespan * 200 # 人工单价50/人天管理费200/天 # 资源均衡目标计算每天资源使用量的方差均值 n_days makespan res_usage_by_day [] for t in range(n_days): usage np.array([0, 0]) for j in range(len(jobs)): if start_time[j] is not None and start_time[j] t finish_time[j]: usage np.array(jobs[j][req]) res_usage_by_day.append(usage) res_usage_by_day np.array(res_usage_by_day) # 计算两个资源种类的方差均值 std_mean np.mean(np.std(res_usage_by_day, axis0)) return makespan, cost, std_mean这段代码里的decode是核心它把工序顺序变成实际可执行的施工计划同时考虑了资源限制。我故意没有用特别高级的解法而是用最简单的逐天扫描因为实际项目里工序只有几十个这种朴素方法跑起来完全够快而且逻辑清楚方便你改成自己的施工规则。成本目标函数里我用了线性关系真实项目里你可以替换成劳务单价、设备租赁价等更复杂的计算。3.3 结果可视化与指标分析跑遗传算法之前先注册工具和算子creator.create(FitnessMin, base.Fitness, weights(-1.0, -1.0, -1.0)) creator.create(Individual, list, fitnesscreator.FitnessMin) toolbox base.Toolbox() toolbox.register(order, random.sample, range(len(jobs)), len(jobs)) toolbox.register(individual, tools.initIterate, creator.Individual, toolbox.order) toolbox.register(population, tools.initRepeat, list, toolbox.individual) # 交叉算子顺序交叉 def cxOrdered(ind1, ind2): size len(ind1) a random.randint(0, size-1) b random.randint(a, size-1) segment ind1[a:b] # 从ind2中删除已经在segment里的工序 remaining [item for item in ind2 if item not in segment] result ind1[:] idx 0 for i in range(size): if a i b: pass else: result[i] remaining[idx] idx 1 return result, result # 变异算子插入变异 def mutInsert(individual): size len(individual) pos1 random.randint(0, size-1) pos2 random.randint(0, size-1) job individual.pop(pos1) individual.insert(pos2, job) return individual, toolbox.register(mate, cxOrdered) toolbox.register(mutate, mutInsert) toolbox.register(select, tools.selNSGA2) # 非支配排序选择selNSGA2是DEAP里现成的多目标选择函数核心就是非支配排序加拥挤度距离排序用它可以兼顾收敛性和多样性。种群我设置成100进化代数200交叉概率0.8变异概率0.2。这些参数不是拍脑袋来的后面第4节会讲我怎么调出来的。运行pop toolbox.population(n100) hof tools.ParetoFront() pop, log algorithms.eaMuPlusLambda(pop, toolbox, mu100, lambda_100, cxpb0.8, mutpb0.2, ngen200, halloffamehof, verboseTrue) # 输出Pareto前沿 front np.array([ind.fitness.values for ind in hof.items]) print(前沿解数量:, len(front)) # 可视化前两个目标 plt.scatter(front[:,0], front[:,1], cblue) plt.xlabel(工期天) plt.ylabel(成本元) plt.title(工期-成本 Pareto前沿) plt.show()跑完之后基本能看到一个从左下到右上的曲线左端是工期短但成本高的方案右端是成本低但工期长的方案。你作为项目经理要做的就是从这条曲线上挑一个点。比如业主咬死工期不放松你就选最左边的解如果资金吃紧就往右选。资源均衡目标可以单独画一条曲线看三个目标之间的两两关系。这里我特别提醒一个坑如果你只用两个目标工期和成本会发现前沿解数量很少甚至只有几个点决策起来很难受。把资源均衡这个第三目标加进去之后解的数量会明显增加前沿分布也更均匀。原因是多一个维度就让原本被支配的解有了存活的可能这算是多目标优化的一个底层逻辑。4 常见问题与调试笔记4.1 约束处理不当导致不可行解我最早跑遗传算法的时候没有在交叉和变异之后重新校验紧前关系结果生成了一堆“非法”的工序顺序比如某道工序的前置工作还没排它就先开始了。解码的时候虽然不会报错但计算出来的工期和成本完全没有参考意义前沿图上一堆乱七八糟的点。解决方案是在交叉和变异算子内部直接加入“修复”逻辑。例如顺序交叉里我先把一个父代的子序列拿出来然后从另一个父代中找没有冲突的工序去补位这样最后生成的个体一定能满足紧前关系。变异也一样插入的位置必须在这个工序的所有紧前工序之后、所有紧后工序之前。这个逻辑不复杂但很多初学的人会忽略我在代码里就写成了带修复的版本。4.2 收敛过早或种群多样性不足另一个常见的问题是算法跑到第30代就停在那儿不动了前沿解挤在一个小角落里怎么跑都是那几个方案。这通常是因为选择压力太大或者变异率太低。我用mu100, lambda_100这种策略等于每一代都重新生成100个新个体再和父代合并选择能有效保住多样性。如果还是收敛早可以把变异率从0.2调到0.3或者把cxpb调低一点。我给另一个项目调试时发现对30道工序的问题种群150、代数400时前沿熵明显更好。注意代数不是越跑越多就越好我试过跑到800代前沿变化已经很小反而浪费时间。有时候跑200代前沿上的解就已经足够稳定了就是要靠log里的代数和适应度趋势来判断。还有一种提高多样性的技巧在适应度评价里加一个很小的随机扰动比如把成本目标乘以(1random.uniform(0, 0.01))。这样能让那些适应度相等或者极其接近的解在NSGA2的拥挤度排序时不至于被误杀前沿上的点会更散。4.3 参数调优经验很多同行问我参数怎么设我实话实说没有万能参数我每次都是先跑一个小的测试集比如先只优化工期和成本跑50代看收敛曲线再决定是否加目标或改参数。下面这张表是我常用的一组起点参数虽然不是最优但足够当基准参数名建议值适用情况种群大小100-200工序20-50个进化代数200-500看收敛情况交叉概率0.7-0.8排序类问题常用变异概率0.1-0.3大问题调高一点选择方式NSGA2多目标最稳我还试过用“精英保留策略”直接把上一代Pareto前沿里的最好的几个解复制到下一代避免丢失。DEAP里的eaMuPlusLambda本身就有这个效果因为子代和父代合并后一起选择所以强解不会轻易被冲走。最后一个小技巧把每天的甘特图和资源直方图画出来。我看很多人在优化完只盯着数值曲线但施工调度最终是要落地到现场排班的。把选中的解解码成甘特图看看两道相邻工序是否真的能衔接资源曲线是否真的平稳这样比单纯看数字更有用。代码里decode函数的输出稍微加工一下就能用matplotlib画出来这一步千万别省。这个项目本身还可以往两个方向扩展一是加入工序之间的空间约束比如两栋楼共用一条施工便道二是把资源可加班和可替换的因素考虑进去比如某类工人不足时能用另一类替代。都是把这个基础框架套上去加几个变量和if判断的事。我做这几个项目下来最大的心得就是先把小案例的代码跑通再往上堆逻辑否则很容易陷入“模型完善但代码永远在debug”的困境。希望这份代码和踩坑笔记能帮你少走我走过的那几段弯路。
返回列表