简介:这份代码包围绕两阶段鲁棒优化中的CCG列与约束生成及Benders分解算法,面向使用MATLAB与YALMIP的研究者和工程师,既适合快速复现原论文基线,也便于按实际决策场景扩展改编。压缩包共5个文件、体积1.44MB,含3篇PDF文献与2个MATLAB脚本;文献涵盖CCG与Benders对比、两阶段鲁棒优化求解方法以及Benders分解算法综述,脚本则提供可运行的案例代码,实现主问题构建、切割生成与迭代求解的完整链路。资源已有2287人学习下载,在鲁棒优化与分解算法主题上具备较好的实践参考热度。通过学习包内代码与配套文献,读者可掌握在YALMIP中搭建两阶段主从问题框架、利用CCG/Benders生成最优割并识别最坏场景的关键思路,继而将原论文方法迁移至新能源出力、供应链需求等含不确定参数的优化问题中,为后续算法对比和工程落地提供可复现基础。
1. 两阶段鲁棒优化的CCG与Benders代码包:拿到就能跑,但先搞懂割长什么样
做过两阶段鲁棒优化的人应该都有体会:模型写出来不难,难的是把CCG或Benders的迭代循环跑通,并让结果能复现原论文的数值。这套带MATLAB+YALMIP源码和对比文献的压缩包,解决的就是“从论文公式到可运行代码”这段最磨人的路。它不是一个教学Demo,而是把两阶段鲁棒优化里两个主流求解框架——列与约束生成(CCG)和Benders分解——做成了可以直接改参数、换模型、对结果的可扩展脚本。适合正在写相关论文的研究生,也适合要把确定性优化改成鲁棒模型的工程师。一个反直觉的结论先放在这里:CCG和Benders看起来是两条完全不同的技术路线,但实际上它们共用同一个“主问题-子问题”迭代骨架,差别只在“切割”的构造方式,理解这一点,改造代码能少走一半弯路。
2. CCG列与约束生成:主问题-子问题迭代与YALMIP代码骨架
2.1 CCG为什么快:列生成与约束生成的双重含义
两阶段鲁棒优化的一般形式是 min cᵀx + max_{u∈U} min_{y∈F(x,u)} dᵀy,第一阶段变量x要现在拍板,第二阶段变量y要等不确定参数u实现后再调整。CCG的思路非常直接:不要试图遍历连续的不确定集U,而是维护一个“已发现的极端场景”集合,每轮找到当前最坏的那个u*,把它对应的第二阶段变量和约束整组塞进主问题。
这里有个关键点:CCG同时在做列生成和约束生成。主问题每加入一个新场景u*,就会新增一组第二阶段变量y^k,这是“列”;同时新增这组变量对应的约束(需求满足、容量限制、与θ的连线),这是“约束”。因为不确定集的极值一定发生在顶点上(线性目标函数在凸多面体上取极值必在顶点),所以每轮找出的u*都是一个个顶点,主问题不断增强对“最坏情况”的描述。相比传统Benders只往主问题丢一条对偶割,CCG牺牲了主问题的规模,换来了更紧的下界,通常迭代次数明显更少。
2.2 CCG主问题与最坏场景搜索:可直接修改的YALMIP实现
下面用一个小规模的设施选址加不确定需求运输问题演示CCG主循环。问题设定是:I个候选设施,建设成本cᵢ,容量capᵢ;J个客户,需求uⱼ在基准值上下波动;第二阶段决策是从设施i到客户j的运输量yᵢⱼ,单位成本dᵢⱼ。第二阶段目标是最小化运费,整体目标是在最坏需求下最小化“建设成本+运费”。
% demo_ccg_2stage.m % 两阶段鲁棒优化:CCG 主循环(YALMIP + MILP 求解器) % 模型:设施选址 + 不确定需求运输 % 第一阶段:0/1 决策是否建设施 % 第二阶段:满足需求的最小运费 nI = 2; % 设施数 nJ = 3; % 客户数 c = [50; 60]; % 建设成本 cap = [10; 10]; % 设施容量 d = [1 2 3; 2 1 2]; % 单位运费,d(i,j) U0 = [4; 5; 6]; % 需求基准值 Delta = [2; 2; 2]; % 需求波动范围 % 盒式不确定集,顶点枚举:2^3 = 8 个节点 vertices = []; for u1 = [U0(1)-Delta(1), U0(1)+Delta(1)] for u2 = [U0(2)-Delta(2), U0(2)+Delta(2)] for u3 = [U0(3)-Delta(3), U0(3)+Delta(3)] vertices = [vertices, [u1; u2; u3]]; end end end U_scen = U0; % 初始场景用基准值 LB = -inf; UB = inf; tol = 1e-3; Kmax = 20; for k = 1:Kmax % ===== 主问题 MP ===== x = binvar(nI, 1); % 是否建设施 theta = sdpvar(1, 1); % 第二阶段成本上界 cons = [sum(x) >= 1]; % 至少建一个设施 obj = c'*x + theta; for s = 1:size(U_scen, 2) % 每个已发现场景加一组变量 y = sdpvar(nJ, nI, 'full'); cons = [cons, sum(y, 2) == U_scen(:, s)]; % 需求等式 cons = [cons, sum(y, 1)' <= cap .* x]; % 容量上限 cons = [cons, y >= 0]; cons = [cons, theta >= sum(sum(d .* y))]; % θ吃掉运费 end optimize(cons, obj, sdpsettings('solver', 'gurobi', 'verbose', 0)); x_val = value(x); theta_val = value(theta); LB = c'*x_val + theta_val; % 下界:主问题最优值 % ===== 子问题 SP:固定 x,找最坏需求 ===== best_sp = -inf; u_star = U0; for vidx = 1:size(vertices, 2) u = vertices(:, vidx); y = sdpvar(nJ, nI, 'full'); cons_sp = [sum(y, 2) == u; sum(y, 1)' <= cap .* x_val; y >= 0]; optimize(cons_sp, sum(sum(d .* y)), ... sdpsettings('solver', 'gurobi', 'verbose', 0)); sp_val = value(sum(sum(d .* y))); if sp_val > best_sp best_sp = sp_val; u_star = u; end end UB = min(UB, c'*x_val + best_sp); % 上界不断收紧 fprintf('iter %2d, LB=%.4f, UB=%.4f, gap=%.6f\n', ... k, LB, UB, (UB-LB)/UB); if (UB - LB) / abs(UB) < tol break; end if ismember(U_scen', u_star', 'rows') break; % 场景重复,已经收敛 end U_scen = [U_scen, u_star]; end这段代码里最需要理解的是主问题里的θ约束。θ被设计成“所有已发现场景下运费的最大值”,所以θ≥dᵀy^k对每个场景k都要成立,MP的目标cᵀx+θ就是在最小化“建设成本+最坏运费”。子问题则是固定x_val后在盒式不确定集顶点上枚举,找出让最小运费最大的那个需求场景u*,这个u*下一轮会被追加进主问题。参数里tol控制收敛精度,Kmax是最大迭代次数,实际跑的时候建议先看gap变化曲线再决定要不要加大Kmax。
一个很容易忽略的细节:solver我写的是gurobi,如果本机没有装,YALMIP会自动找可用求解器,但binvar变量必须有MILP求解器才能处理。MATLAB自带的intlinprog也能跑,把sdpsettings里的solver改成'intlinprog'即可,代价是求解速度慢一些,模型调通后建议换商用求解器。
3. Benders分解落地:对偶割的推导与迭代实现
3.1 从max-min到对偶问题:Benders割是怎么来的
Benders分解不像CCG那样往主问题里塞整个场景的第二阶段变量,而是把第二阶段问题取对偶,形成一条只含第一阶段变量x和对偶变量的线性约束。这条约束叫Benders最优性割,它的作用是在主问题里给θ一个不断收紧的下界。
核心推导是这样的:对固定的x和u,第二阶段内层min问题是一个线性规划,取对偶后目标函数变成λᵀu - μᵀ(cap ⊙ x),其中λ是需求等式的对偶变量,μ是容量不等式(≤约束)的对偶变量。因为对偶变量只依赖对偶约束而不依赖x,所以对任何新的x',(λ*, μ*)仍然可行,于是 λᵀu- μᵀ(cap ⊙ x') 始终是第二阶段最优值的一个下界。把这个下界做成主问题里θ的约束,就得到了Benders割。每一轮从子问题里捞出一组(λ, μ*, u*),就能给主问题加一条比上一轮更紧的割。
3.2 Benders主问题与割累积:一套比CCG更省的MP
Benders的主问题里只有x和θ,外加一堆累积的割不等式,变量规模远小于CCG的主问题,这是它处理大规模问题的本钱。下面是同一选址问题的Benders循环,SP部分仍然枚举盒式顶点,但重点是从约束句柄里取出对偶变量。
% demo_benders_2stage.m % Benders 分解主循环:同一套设施选址问题 % 延续上一节的 nI/nJ/c/cap/d/U0/Delta/vertices 定义 x = binvar(nI, 1); theta = sdpvar(1, 1); cons0 = [sum(x) >= 1, theta >= 0]; % θ 先给一个平凡下界 cuts = []; % 累积割的 cell for k = 1:Kmax cons = cons0; for t = 1:length(cuts) lam = cuts(t).lam; u = cuts(t).u; mu = cuts(t).mu; % Benders 最优性割:θ ≥ λ'*u - μ'*(cap ⊙ x) cons = [cons, theta >= lam'*u - mu'*(cap .* x)]; end optimize(cons, c'*x + theta, ... sdpsettings('solver', 'gurobi', 'verbose', 0)); x_val = value(x); theta_val = value(theta); LB = c'*x_val + theta_val; % ===== SP:固定 x 枚举顶点,找最坏场景并取对偶 ===== best_sp = -inf; for vidx = 1:size(vertices, 2) u = vertices(:, vidx); y = sdpvar(nJ, nI, 'full'); cons_sp = [sum(y, 2) == u; sum(y, 1)' <= cap .* x_val; y >= 0]; optimize(cons_sp, sum(sum(d .* y)), ... sdpsettings('solver', 'gurobi', 'verbose', 0)); sp_val = value(sum(sum(d .* y))); if sp_val > best_sp best_sp = sp_val; u_star = u; % 取出需求等式和容量约束的对偶变量 lam = dual(cons_sp(1)); mu = dual(cons_sp(2)); end end UB = min(UB, c'*x_val + best_sp); cuts(end+1) = struct('lam', lam, 'u', u_star, 'mu', mu); fprintf('iter %2d, LB=%.4f, UB=%.4f, gap=%.6f\n', ... k, LB, UB, (UB-LB)/abs(UB)); if (UB - LB) / abs(UB) < tol break; end end注意dual的提取顺序非常严格,它只认你在cons_sp里写约束时的排列顺序:cons_sp(1)是需求等式,对应λ;cons_sp(2)是容量不等式,对应μ。建模顺序一变,对偶变量就全错了,这是Benders实现里最容易翻车的点。另外,Benders的主问题在不含任何割的时候,θ没有上界约束,目标函数会把θ往负无穷推,所以我在cons0里加了theta >= 0作为平凡的初始下界,否则第一轮MP就可能异常。
实际对比起来,Benders的主问题变量少,每轮只加一条约束,计算很快;但它的割相对“松散”,经常要迭代几十轮才收敛,而CCG往往十几轮就停了。这也是为什么现在主流的两阶段鲁棒文献更偏爱CCG。
4. CCG与Benders如何选:收敛速度、割类型与模型规模的取舍
4.1 两种割的本质差异:几何视角与文献结论
从几何上看,CCG和Benders都在做同一件事:用一个逐步精细化的多面体去逼近第二阶段值函数。Benders的割是从对偶空间投影下来的支撑超平面,每轮加一个,主问题始终是个小规模LP/MILP;CCG则是直接把不确定场景对应的原始变量和约束整个投射进主问题,相当于把值函数的关键点连同局部结构一起复制进来。
文献里的共识是:CCG的收敛速度在迭代轮数上几乎总是优于Benders,尤其是在第二阶段子问题约束较多的模型中,CCG往往用不到Benders一半的迭代次数就达到同样gap。但代价是CCG主问题的规模随场景数线性膨胀,当第二阶段变量本身很多时,主问题会变得非常庞大。Benders恰好相反,每轮割很廉价,主问题长期保持精简,适合割数量能控制在可接受范围的大规模问题。实践里还发现一个规律:当不确定集维数低、第二阶段LP规模适中时,Benders的累积求解时间可能反而占优;而当不确定集结构复杂、需要少轮次快速收敛时,直接选CCG。
4.2 适用场景对照表与选型建议
| 对比项 | CCG(列与约束生成) | Benders 分解 |
|---|---|---|
| 割的内容 | 完整场景变量+约束 | 一条对偶线性不等式 |
| 主问题规模增长 | 快(每轮加一组y和约束) | 慢(每轮只加一条割) |
| 典型迭代轮数 | 少,通常5~20轮 | 多,常需数十轮 |
| 子问题处理 | 只需解内层min找最坏场景 | 必须取对偶变量生成割 |
| 第二阶段变量多 | 主问题膨胀,求解变慢 | 割仍然是一维,影响小 |
| 实现与调试难度 | 低,直观 | 中,对偶提取易出错 |
| 适合场景 | 论文复现、中小规模、快速收敛 | 大规模LP结构、MP必须精简 |
我自己的选型习惯是:复现论文阶段先写CCG,因为它的子问题只用求最优值不需要对偶变量,代码出错率低;等模型定型、确需要推到很大规模时再改成Benders。反过来一开始就写Benders,容易在dual提取和割正负号上浪费大量时间。
5. 复现避坑指南:五个让两阶段模型翻车的常见错误
5.1 MP有界但gap长期不降
现象:主问题每轮都在正常求解,LB和UB各自变化,但gap卡在某个值附近震荡,达到设定迭代上限还没收敛。原因通常是两个:一是子问题找到的u其实已经出现过,但主问题重复添加同一场景,变量重复但θ并不变紧;二是MP和SP的求解器容差设置不一致,MP用默认1e-7,SP用1e-6,两边数值精度互相拉扯。解决方法是加场景去重判断,代码里用ismember(U_scen', u_star', 'rows')检查u是否已存在;同时把tol设为1e-3到1e-4之间,并在MP和SP的sdpsettings里显式设置一样的求解器容差。
5.2 求解器报“No suitable solver”
现象:明明装了MATLAB,模型也建好了,一运行就提示找不到可用的求解器,或者提示MILP问题需要MILP求解器。原因:YALMIP默认的求解器搜索列表里没有能处理binvar的求解器,很多人装了gurobi却没在YALMIP里配置路径,或者本机只有linprog这类连续LP求解器。解决:临时改用MATLAB自带intlinprog,sdpsettings('solver','intlinprog');长期则是正确安装并配置gurobi或cplex的YALMIP接口。注意intlinprog能解MILP,但大规模时性能差距明显,调通后尽快换。
5.3 SP无界或者MP给出荒谬的x
现象:SP求解器返回“problem is unbounded”,或者主问题求出的x明显不合理,比如一个设施都不建导致第二阶段无可行解。原因:子问题里某些需求顶点在当前x下根本满足不了,运输问题不可行,对偶问题自然无界;更深一层是主问题的容量约束没有和第一阶段x耦合好,比如cap ⊙ x在某些维度上为0,而对应需求又必须被满足。解决:在SP里加人工松弛变量并赋予大惩罚系数,优先保证SP永远有界;同时在MP里加总容量约束,比如sum(cap .* x) >= max(sum(vertices)),从根上杜绝不可行。
5.4 dual读出来是空的
现象:Benders代码运行到dual(cons_sp(1))时报错,提示dual extraction不可用,或者返回的变量里全是NaN。原因:YALMIP不是对所有求解器都支持dual提取,有些开源求解器或intlinprog不会回传对偶信息;另一个常见原因是约束句柄顺序和建模顺序不一致,取到了错误的约束。解决:先用check(cons_sp)确认对偶可行性;更换支持对偶提取的求解器,比如gurobi、cplex、mosek;同时严格按建模顺序记录约束句柄,最好在注释里标明第几个约束对应哪个对偶变量。
5.5 把代码套进自己的模型后维度对不上
现象:按照压缩包里的模板改数据,结果主问题报维度不匹配,或者SP里sum(y, 2)和u的长度对不齐。原因:资源包的代码是面向原论文的特定模型结构,客户数、设施数、约束排列都写死在脚本里,直接替换数据不会自动适配新结构。解决:把nI、nJ、cap、U0、Delta这些全部抽成参数结构体,凡是尺寸相关的循环一律用size()动态获取;特别要注意需求等式sum(y,2)是按客户维度汇总,容量约束sum(y,1)'是按设施维度汇总,两个方向的维度含义千万别搞反。
6. 参数调优与收敛验证:把CCG脚本改造成你自己的模型
拿到资源包之后,直接改数据是最低效的用法。我一般会把主问题和子问题分别封装成函数,输入一个data结构体,输出x和u*,这样换模型时只需要动数据定义和约束行。
data.c、data.cap、data.d、data.U0、data.Delta放进去,函数内部用length(data.U0)获取维度,所有约束用循环生成,而不是手写固定下标。改造的第一个验证点是让子问题目标值和你手算的某个确定场景一致:把不确定集固定成基准值U0,跑一遍SP,如果运费不等于手算结果,说明约束方向或者sum维度有问题。
收敛验证方面,我习惯在迭代循环里保存每一轮的LB、UB和x_val,退出循环后画三条曲线:LB曲线、UB曲线、gap曲线。三张图能直接看出算法是卡在割质量上还是卡在数值容差上。如果LB曲线一直不动,多半是主问题里θ的约束不够紧,检查新场景是否真的加了进去;如果UB曲线波动很大,则是SP每轮找到的最坏场景不稳定,通常是顶点枚举时漏了某些顶点或者不确定集定义有误。
另外有一个容易被忽视的参数:初始场景的选择。资源包代码用U0做初始场景,这在很多模型里足够,但如果不确定集的波动范围很大,从U0出发会浪费好几轮迭代。我通常会同时尝试全上界、全下界、基准值三个初始场景,对比总迭代轮数,选效果最好的作为默认值。这个操作就几行代码,但对收敛速度的影响往往很显著。
最后说一个我自己踩过的坑:CCG代码调通后直接拿去跑大规模数据,J增到15以上时顶点枚举会爆炸,2^15个顶点根本枚举不完。遇到这种情况,把盒式集改成预算不确定集,SP不必枚举顶点,而是解一个带排序逻辑的线性规划或者直接对偶后按λ符号定界。从那以后我每次拿到新的两阶段鲁棒问题,都先确认不确定集结构再决定SP用枚举还是解析法,这个习惯帮我省掉了大量无效迭代。希望帮到你。
本文还有配套的精品资源,点击获取