ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

列生成算法实战:从混合整数规划到多生产者共享资源调度的分解优化

列生成算法实战:从混合整数规划到多生产者共享资源调度的分解优化 简介基于列生成算法的煤炭供应链调度方案是一份面向具备运筹学与优化算法基础的研究人员、工程师及研究生的论文及详细代码说明。论文围绕多生产者共享运输工具形成的资源约束问题将系统分解为生产计划层与资源调度层采用列生成分布式决策方法并与集成混合整数规划、拉格朗日松弛方法对比验证了求解效率上的显著优势。资源压缩包内含1个PDF文档688KB除理论建模与算法框架外还提供基于pulp库实现的Python代码及逐步解释覆盖主问题/子问题构建、对偶价格获取、列生成迭代终止判断、资源分配与调度具体步骤。目前已有62人学习适合需要理解并实现列生成算法、掌握将大型优化问题拆分为小型子问题的读者可结合煤炭供应链场景直接复现实验并进一步探索动态环境适应、随机需求处理及多利益方目标平衡等扩展方向。1. 煤炭供应链调度为什么不能只靠整体混合整数规划三座煤矿共享同一条铁路干线的运力每座矿有自己的订单、交付期限和延期惩罚而列车在每个周期只有固定节数。把这三个生产者的生产计划、库存决策和资源占用全部揉进一个混合整数规划模型里求解变量规模会随着生产者数量和周期数乘积式膨胀商用求解器在资源耦合最紧的时段往往要跑几十分钟甚至更久。这篇论文的解法是把决策拆成两层生产计划层由各生产者自己负责资源调度层由一个集中式协调者通过价格信号驱动再用列生成算法把两层串起来迭代。实际对比实验里这种分布式决策框架的求解效率明显优于集成MIP和拉格朗日松弛资源利用率和计划质量还能保持在同等水平。对做供应链排程、运输调度、微电网经济调度这类多主体共享稀缺资源问题的工程师来说这套框架的价值不只是某个算例更快而是给了你一条把大规模耦合优化问题切成可维护模块的标准化路径。2. 把耦合约束切出主问题生产计划层与调度层的分解逻辑2.1 为什么集成模型会“维度爆炸”先看整体模型的形态每个生产者内部有订单排序、开始时间、完工时间这些本地约束同时所有生产者共用一个资源池任意周期内所有生产者的资源消耗总和不能超过容量。集成MIP直接写的时候0-1变量至少要覆盖“订单 × 可用开始时段”的组合加上库存流变量和资源耦合辅助变量矩阵会被填充得很稠密。更麻烦的是链接约束linking constraints。这类约束把多个生产者的决策变量耦合在同一个不等式里破坏了很多商用求解器擅长的块对角结构。预处理想通过bound tightening把耦合约束压缩掉但资源容量越紧张耦合约束越活跃问题就变得越难。论文里把这个问题建模为“具有链接约束的多生产者调度”本质就是想从结构上绕开这种稠密矩阵。方法优点缺点适用场景集成MIP理论上能得到全局最优解变量规模指数膨胀资源紧约束时求解时间失控小规模验证算例拉格朗日松弛把全局约束乘子化结构清晰对偶价格震荡严重乘子更新步长难调中等规模、能接受近似解列生成主问题只保留少量候选列内存需求低收敛速度受初始列和子问题求解质量影响大规模多生产者共享资源网络拉格朗日松弛和列生成表面都用了对偶分解的思路但信息流完全不同。拉格朗日把全局约束直接加进目标函数里每次迭代靠次梯度更新乘子而列生成把全局约束继续放在一个叫限制主问题RMP的小模型里每次迭代都用LP最优解处的精确对偶值来指导定价。这种差异在资源容量接近饱和的问题里会被放大也是论文对比实验里列生成显著占优的主要原因。2.2 分解的边界本地约束与全局约束我在复现时最关注的一个问题是什么约束留在主问题什么约束沉到子问题答案其实很标准。凡是可以按单个生产者独立判断的约束全部交给子问题凡是需要跨生产者协调资源的约束全部留在主问题。资源容量约束是典型的全局约束任何一个生产者单独看这个容量约束都没有意义因为单看一条计划不越界不够所有计划同时被选中才需要检验。用数据结构来表达一条“列”是这样定义的# 一个“列”就是某个生产者的一整条候选计划 column { producer: 0, schedule: [ {order: 0, start: 0, end: 1}, # 订单0在周期0开工 {order: 1, start: 1, end: 2}, ], cost: 7.0, # 延期惩罚等合计成本 resource_usage: [1.5, 1.5, 0.0, 0.0, 0.0] # 各周期占用的运力资源 }这里的resource_usage是把该生产者的调度决策投影到资源维度后得到的一条曲线。子问题内部怎么排序、订单怎么插入主问题完全不关心主问题看到的只是成本、资源消耗曲线以及这个列属于哪个生产者。本地约束全文都在子问题里独立保证这样就形成了解耦。2.3 对偶价格是两层之间的桥梁分解之后两层之间的信号就是对偶变量影子价格。主问题的资源容量约束被求解后每个周期都会产出一个对偶值该值的经济含义是“该周期每增加一单位运力总成本大约能下降多少”。如果周期1的容量是松弛的对偶价格就是0子问题的新计划占用周期1的资源不会产生机会成本如果周期3的容量很紧对偶价格可能是2.5那么一条计划在周期3占用一个单位运力就要在它的约简成本里多扣掉2.5以权衡占用的机会代价。我一般会把这个过程理解为主问题先给各周期资源“标价”子问题拿着这个价格表评估自己新计划的真实价值再把值得加入的计划交回主问题重新定价。每个生产者的凸性约束必须选出恰好一份计划也有自己的对偶值它代表“让该生产者参与本次组合选择”这个事实的机会成本。两个对偶值一组装就是后面代码里约简成本的计算依据。3. 限制主问题的构建与列定价代码级拆解3.1 初始列生成与RMP构建列生成需要一个可行的起点。这里用最早截止日期优先EDF作为启发式为每个生产者生成第一份初始计划。初始计划不要求全局最优甚至不要求资源容量可行只要每条列内部合法即可因为主问题会通过λ权重决定到底选不选某条列。master LpProblem(RMP, LpMinimize) lambda_vars {} columns [] for p in self.producers: plan p.generate_initial_plan() # EDF启发式得到初始计划 col_idx len(columns) columns.append({ producer: p.id, schedule: plan, cost: p.calculate_cost(plan), usage: p.calculate_resource_usage(plan), }) # λ变量代表这条计划被选中的权重RMP阶段用连续变量 lambda_vars[col_idx] LpVariable( flambda_{col_idx}, lowBound0, upBound1, catContinuous ) # 目标最小化所有被选中计划的成本之和 master.setObjective( lpSum(columns[i][cost] * lambda_vars[i] for i in range(len(columns))) ) # 凸性约束每个生产者必须恰好被选出一条计划 for p in range(self.num_producers): master ( lpSum(lambda_vars[i] for i, col in enumerate(columns) if col[producer] p) 1, fProducer_{p}_selection, ) # 资源容量约束每个周期的总资源占用不超过容量 for t in range(self.num_periods): master ( lpSum(columns[i][usage][t] * lambda_vars[i] for i in range(len(columns))) self.resource_capacity[t], fResource_Capacity_{t}, )这里最关键的一点是catContinuous。有些第一次接触列生成的读者会想当然把λ设成整数结果求解后对偶变量读出来全是0因为整数规划的乘子信息已经丢失。RMP阶段的λ∈[0,1]表示“候选计划之间的凸组合选择”只有当需要严格整数解时才引入分支定价或后续的启发式整数化。约束命名也不是随意写的Producer_0_selection和Resource_Capacity_2这些名称后面要直接用来读取对偶值名称必须稳定。3.2 定价子问题约简成本怎么算主问题求解完成后从constraints[name].pi读出两组对偶生产者凸性约束的对偶dual_producer[p]以及每个周期资源容量约束的对偶dual_resource[t]。子问题要做的事情是在当前对偶价格下为每个生产者找一条约简成本最小的新计划。def solve_subproblem(producer, duals, num_periods): # 生成两条候选EDF调度和最小惩罚调度 candidates [ producer.earliest_due_date_schedule(), producer.least_penalty_schedule(), ] best None for schedule in candidates: cost producer.calculate_cost(schedule) usage producer.calculate_resource_usage(schedule) rc cost rc - duals[producer][producer.id] # 扣除生产者凸性对偶 rc - sum( duals[resource][t] * usage[t] for t in range(num_periods) ) # 扣除资源占用的机会成本 if best is None or rc best[0]: best (rc, cost, usage, schedule) return { producer: producer.id, schedule: best[3], cost: best[1], usage: best[2], reduced_cost: best[0], }约简成本的拆解逻辑是新列的真实成本减去该生产者被纳入组合选择的机会成本再减去它占用资源所产生的机会成本。如果算出来的rc是负数说明在当前对偶价格体系下这条计划的总体现价比现有列池里的组合更好值得加入RMP。容差-1e-6起的是浮点误差抵消作用。由于主问题的LP求解和对偶提取都有数值误差rc刚好在-1e-10这种量级时不应该再触发列添加否则迭代会在数值噪声附近空转。实际调参时这个阈值取多少要看模型尺度成本是百万级时用-1e-6没问题成本只有几十的话建议放宽容差到-1e-9再观察列池增长。3.3 添加新列为什么这里要重建而不是增量修改原复现代码里用了master_problem column[cost] * new_var和master_problem.constraints[...] new_var 1这种写法我在自己跑的时候发现两个问题。第一prob 表达式在PuLP里是设置目标函数不是给现有目标追加项多次调用会把目标覆盖成只有最后一列。第二直接对constraints里的LpConstraint对象做复合赋值在当前版本的PuLP中并不可靠。def add_column(master, lambda_vars, columns, new_col, num_producers, num_periods, capacity): idx len(columns) columns.append(new_col) # 新λ变量 lambda_vars[idx] LpVariable( flambda_{idx}, lowBound0, upBound1, catContinuous ) # 重建目标函数从columns列表全量构造 master.setObjective( lpSum(columns[i][cost] * lambda_vars[i] for i in range(len(columns))) ) # 重建生产者凸性约束 for p in range(num_producers): master.constraints[fProducer_{p}_selection] ( lpSum(lambda_vars[i] for i, col in enumerate(columns) if col[producer] p) 1 ) # 重建资源容量约束 for t in range(num_periods): master.constraints[fResource_Capacity_{t}] ( lpSum(columns[i][usage][t] * lambda_vars[i] for i in range(len(columns))) capacity[t] )每次加列后从columns列表全量重建目标与约束复杂度是O(当前列数 × 周期数)对于教学实现完全够用。这种写法的好处是每一步的状态都和数据结构完全一致不容易出现“目标里加了列但约束没同步”这类一致性bug。工程化改造时可以改成维护稀疏矩阵的增量行性能高一个量级但调试难度会显著上升建议先把全量重建跑通再考虑优化。3.4 迭代流程与终止条件整个列生成主循环可以概括为“求解RMP → 提取对偶 → 定价 → 加列 → 再求解”。我把每个阶段需要盯住的指标整理成表阶段核心操作需要关注的信息常见问题初始化用EDF生成初始列初始列的成本和资源曲线每个生产者都必须有至少一条列LP求解solve RMP目标函数值、所有约束的pi值求解状态非Optimal时不能读取对偶定价每个子问题计算约简成本rc是否小于负容差子问题不是精确求解时可能漏定价加列重建RMP列池数量变化目标函数误被覆盖、约束不同步终止全部子问题的rc ≥ -1e-6最后几轮目标值是否停滞过早终止漏掉真正改进列原代码里的子问题只枚举了EDF和最小惩罚两种排序方案。这意味着即使市场上存在更优列只要不在枚举集合里就发现不了CG可能提前停机。复现时如果想验证收敛行为可以把枚举换成精确动态规划第5章会展开讲。4. 求解速度从哪里来与集成MIP和拉格朗日松弛的对比实验4.1 变量数量才是真正的瓶颈列生成的优势不在算法复杂度理论而在内存访问模式和求解器SIMD效率。集成MIP在模型里把所有订单、时段、生产者的组合一次性铺开单纯形表的列数是所有组合的数量级而列生成的RMP只维护已经生成出来的候选列。假设3个生产者、5个周期、每个生产者4个订单集成MIP至少要建 4×5×360 个0-1变量和辅助变量而CG在迭代收敛后RMP里的λ变量通常只有30到50个且主问题矩阵每一列都稀疏。可以在每次迭代后打印对偶价格变化幅度并和拉格朗日松弛的次梯度更新对比。论文里的结论是CG显著快于拉格朗日松弛复现时观察到的现象一般分两类一是RMP的目标函数值单调收敛不再来回抖二是迭代后期新增列数大幅下降而目标值几乎不动。4.2 迭代观测脚本目标值、列数与对偶价格我写了个轻量追踪段来观测每轮迭代的质量previous_objective None for it in range(max_iter): master.solve() duals extract_duals(master) new_columns [] for sp in subproblems: candidate sp.solve_subproblem(duals) if candidate[reduced_cost] -1e-6: add_column( master, lambda_vars, columns, candidate, num_producers, num_periods, capacity ) new_columns.append(candidate) objective master.objective.value() dual_change 0.0 if previous_objective is not None: dual_change abs(objective - previous_objective) / abs(previous_objective) print( fiter{it1:3d} obj{objective:10.4f} fcols{len(columns):3d} new{len(new_columns)} fobj_delta{dual_change:.3e} ) if not new_columns: print(no negative reduced cost column found) break previous_objective objective从日志里判断收敛质量看两个地方。一是目标函数值是否单调下降CG的RMP每次加列后可行域扩大或至少不缩小所以目标值应该单调改善如果看到目标值反弹说明约束重建写错了。二是对偶价格的变化幅度资源容量紧时对偶价格会在前几轮大幅跳动后面趋于稳定。若对偶价格持续震荡超过几十轮优先怀疑子问题没有真正最小化约简成本而不是去调主问题容差。4.3 列生成什么时候会退化任何算法都有边界。资源容量严重松弛时所有资源约束的对偶价格都是0子问题没有任何资源信号列生成退化成一个纯计划枚举器此时的迭代没有意义直接用每个生产者的独立最优解就能得到全局解。资源容量极紧时主问题的可行域很小初始列只要有一条不符合容量约束RMP可能在好几轮内都产生不了有效对偶因为LP找不到可行解。可以观察资源利用率趋近容量上限时的收敛曲线。我一般在实验里把容量设成总需求量的60%、80%、95%三档分别跑CG和集成MIP。60%时CG优势不突出95%时CG会在容量瓶颈周期反复定价同类型列这时需要检查列池里是否存在重复模式必要时加列池去重。5. 收敛不稳定时对偶价格稳定化与精确子问题改造5.1 对偶价格震荡与boxstep约束拉格朗日松弛的对偶价格震荡是因为每次次梯度更新都在“过冲”列生成的RMP虽然取的是LP最优乘子但资源容量紧时对偶价格在相邻迭代也会出现较大摆动。boxstep稳定化是常见做法限制对偶价格每一步只能变化不超过带宽比如20%。class StabilizedDuals: def __init__(self, box_width0.2): self.history [] self.box_width box_width def restrict(self, dual): if not self.history: self.history.append(dual) return dual prev self.history[-1] low prev - abs(prev) * self.box_width high prev abs(prev) * self.box_width clipped float(np.clip(dual, low, high)) self.history.append(clipped) return clippedbox_width取0.1到0.3比较常见。窄了收敛慢每轮对偶变化被压得太小宽了跟没加一样。这个技巧的原理是牺牲少数迭代次数换取整体的定价稳定性。5.2 子问题从启发式换成动态规划枚举两个启发式调度方案虽然快但不保证找到最小约简成本CG的终止性在理论上就不成立。工业级实现通常把子问题做成动态规划状态是(已安排订单集合, 当前周期)转移是枚举下一个要插入的订单。def exact_pricing(producer, duals): n len(producer.orders) dp {(0, 0): 0.0} # (mask, period) - 累计成本减去资源占用对偶 full_mask (1 n) - 1 for mask in range(1 n): for t in range(producer.num_periods): if (mask, t) not in dp: continue cur dp[(mask, t)] if t 1 producer.num_periods: nxt (mask, t 1) dp[nxt] min(dp.get(nxt, 1e18), cur) for j in range(n): if mask j 1: continue order producer.orders[j] end min(t 1, producer.num_periods - 1) cost producer.cost_of(order, t, end) usage producer.resource_usage_of(order, t, end) price cost - sum( duals[resource][tt] * usage[tt] for tt in range(producer.num_periods) ) nm mask | (1 j) key (nm, end) dp[key] min(dp.get(key, 1e18), cur price) best_rc min( (dp[(full_mask, t)] - duals[producer][producer.id]) for t in range(producer.num_periods) if (full_mask, t) in dp ) return best_rcDP只在子问题规模真的大到需要精确求解时才必要订单数小于8时枚举所有排列也能接受。DP的终点是任意可以结束的时刻从所有完成状态里挑约简成本最小的。这样定价子问题成为精确算法CG的终止性才有理论保证。5.3 可行性审计解出来不等于能上线λ是连续变量最终拿到的RMP解只代表凸组合层面的资源分配不等于直接可执行的整数调度。上线前的审计步骤不能省先检查每个生产者的λ之和等于1再检查各周期资源用量不超过容量最后把选中的列组装成完整排程。def audit(master, columns, lambda_vars, capacity): for p in range(num_producers): lam_sum sum( lambda_vars[i].value() for i, col in enumerate(columns) if col[producer] p ) assert abs(lam_sum - 1.0) 1e-6, fproducer {p} lambda sum {lam_sum} total_usage np.zeros(len(capacity)) for i, col in enumerate(columns): v lambda_vars[i].value() if v and v 1e-6: total_usage np.array(col[usage]) * v print(resource_usage:, total_usage) print(capacity_gap:, total_usage - np.array(capacity))除了可行性还要看资源利用率的分布。容量利用率饱和的周期往往就是未来需要放宽容量或增加运力的瓶颈这个信息比单纯的目标函数值更有业务决策价值。把审计脚本挂在每次实验末尾无论后续做分支定价还是直接做启发式整数化都能第一时间暴露模型结构层面的问题。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进