GAMS运输问题实战:从线性规划建模到求解结果解读 1. 从一车皮钢材的调度说起运输问题到底在算什么很多人第一次接触线性规划都是从运输问题入门的。原因很简单它的业务场景足够直观数学模型又足够典型几乎把线性规划的几个核心概念——决策变量、目标函数、约束条件、供需平衡——全都串了一遍。我在带新人的时候也习惯拿运输问题当第一个案例因为它能让人在半小时内建立起对优化这件事的具象认知。先把场景摆出来。假设有两个钢材仓库分别位于城北和城西库存量分别是 35 吨和 25 吨。同时有三个工地需要钢材需求量分别是 20 吨、25 吨、15 吨。从每个仓库到每个工地的单位运输成本不一样因为距离、路况、装卸条件都不同。现在的问题是怎么安排调运方案才能让总运输成本最低这个问题看起来简单但如果你手动去试会发现组合方式并不少。两个仓库、三个工地就有 6 条运输线路每条线路运多少理论上可以有无穷多种分法。真正要找到成本最低的那一组靠拍脑袋是不行的。这就是线性规划要解决的问题——在一组线性约束下寻找一个线性目标函数的最小值或最大值。运输问题的数学结构有几个鲜明特征。第一决策变量是每条线路上的运输量通常记作 xij表示从第 i 个仓库运往第 j 个工地的数量。第二目标函数是所有线路运输量乘以对应单位成本的总和。第三约束条件分两类一类是每个仓库的发出量不能超过它的库存另一类是每个工地的接收量必须满足它的需求。如果总供应等于总需求就是平衡运输问题如果不相等就需要引入虚拟仓库或虚拟工地来配平。GAMS 在这个场景里的角色是一个建模语言和求解环境。它不负责替你思考业务逻辑但能把你写下来的数学关系翻译成求解器能读懂的形式然后调用求解器算出结果。相比直接在求解器里写矩阵GAMS 的好处是模型的可读性极强——你写出来的东西几乎就是数学公式本身改参数、换数据、调结构都很方便。这也是为什么在运筹学教学和工业级优化项目里GAMS 一直有一席之地。提示运输问题是线性规划中最经典的入门模型之一但它的结构可以扩展到很多实际场景比如生产排程、库存调拨、人员分配、网络流量分配等。把运输问题吃透后面学更复杂的模型会轻松很多。2. GAMS 的建模语言长什么样从集合到方程的骨架2.1 集合声明先把谁参与运算定义清楚GAMS 建模的第一步通常不是写公式而是声明集合。集合就是参与运算的对象清单。在运输问题里我们需要两个集合仓库集合和工地集合。写法很直接Set i warehouses / north, west / j markets / site1, site2, site3 /;这段代码的意思是定义集合 i别名是 warehouses包含 north 和 west 两个元素定义集合 j别名是 markets包含 site1、site2、site3 三个元素。集合名后面的斜杠里就是元素列表。GAMS 对大小写不敏感但为了可读性我习惯把集合名用小写别名用有意义的英文单词。集合声明看起来简单但它是整个模型的地基。后面所有的参数、变量、方程都是建立在集合的笛卡尔积之上的。比如 i 和 j 的笛卡尔积就是 2×36 条线路每条线路对应一个运输量变量和一个单位成本参数。如果你集合定义错了后面全盘皆错。2.2 参数赋值把业务数据填进去集合定义好之后接下来要把具体数据填进去。运输问题需要三类数据每个仓库的供应量、每个工地的需求量、每条线路的单位运输成本。GAMS 里用 Parameter 来声明Parameter supply(i) supply capacity demand(j) demand requirement cost(i,j) unit transportation cost; supply(i) / north 35, west 25 /; demand(j) / site1 20, site2 25, site3 15 /; cost(i,j) / north.site1 8, north.site2 6, north.site3 10 west.site1 9, west.site2 12, west.site3 7 /;这里有几个细节值得注意。第一supply(i) 是一维参数只跟仓库有关cost(i,j) 是二维参数跟仓库和工地都有关。第二赋值时用斜杠包裹元素之间用逗号分隔。第三二维参数的写法是仓库.工地中间用点号连接。第四如果某个组合没有赋值GAMS 默认它是 0这在运输问题里通常意味着这条线路不通或者成本为零需要根据实际情况判断是否合理。我见过不少新手在这里踩坑把 cost 的维度写反了或者点号前后写错了顺序结果求解出来的方案完全不合理。建议赋值完之后用 display 语句把参数打印出来检查一遍display supply, demand, cost;这个习惯能帮你省下大量调试时间。2.3 变量与方程把数学公式翻译成 GAMS 语法数据准备好之后就进入核心部分声明变量和方程。运输问题的变量只有一个就是每条线路的运输量Positive Variable x(i,j) transportation quantity;Positive Variable 表示这个变量非负符合运输量的物理意义。接下来是目标函数和约束条件。GAMS 里用 Equation 来声明方程先声明再定义Equation obj total cost supply_con(i) supply constraint demand_con(j) demand constraint; obj.. sum((i,j), cost(i,j) * x(i,j)) e 0; supply_con(i).. sum(j, x(i,j)) l supply(i); demand_con(j).. sum(i, x(i,j)) g demand(j);这里出现了几个 GAMS 的核心语法元素。sum((i,j), ...) 是求和操作对 i 和 j 的所有组合求和。e 表示等于l 表示小于等于g 表示大于等于。obj 方程写成 e 0 是一种常见写法意思是目标函数表达式等于零求解器会自动把它识别为需要最小化的目标。有一点需要特别说明GAMS 里方程的定义和数学上的等式不太一样。obj.. sum(...) e 0 并不是说总成本等于零而是说目标函数就是等号左边的表达式求解器会根据 solve 语句里的 min 或 max 来决定优化方向。这个写法初看有点别扭但习惯了就好。2.4 模型声明与求解最后一步把一切串起来方程定义完之后需要声明模型并调用求解器Model transport / all /; Solve transport using lp minimizing obj;Model transport / all / 的意思是定义一个叫 transport 的模型包含所有已声明的方程。Solve 语句指定用 lp线性规划求解器目标是最小化 obj。执行之后GAMS 会输出求解状态、目标函数值、变量取值等信息。整个模型写下来不到 30 行但已经完整描述了一个运输优化问题。这就是 GAMS 的魅力它把建模的门槛降得很低让你能把精力集中在业务逻辑上而不是矩阵变换和求解器接口上。3. 供需不平衡时怎么办虚拟节点的引入逻辑与实操3.1 为什么总供应和总需求经常对不上前面假设的是平衡运输问题总供应 352560 吨总需求 20251560 吨刚好相等。但实际业务里这种情况反而是少数。更多时候是供应大于需求或者需求大于供应。比如仓库总库存 60 吨但工地只需要 50 吨多出来的 10 吨怎么办或者工地需要 70 吨但仓库只有 60 吨缺口 10 吨怎么处理线性规划的标准形式要求约束条件必须是等式或不等式但求解器对不平衡的处理能力有限。如果直接写 supply_con(i).. sum(j, x(i,j)) l supply(i) 和 demand_con(j).. sum(i, x(i,j)) g demand(j)当总供应大于总需求时模型可能有可行解但求解结果里会有剩余库存没有明确表达当总需求大于总供应时模型直接不可行。3.2 虚拟仓库与虚拟工地的构造方法标准做法是引入虚拟节点把不平衡问题转化为平衡问题。具体分两种情况。第一种总供应大于总需求。引入一个虚拟工地它的需求量等于总供应减去总需求。从每个仓库到虚拟工地的单位运输成本设为 0表示剩余库存就地存放不产生运输成本。这样总供应和总需求就相等了模型变成平衡运输问题。第二种总需求大于总供应。引入一个虚拟仓库它的供应量等于总需求减去总供应。从虚拟仓库到每个工地的单位运输成本设为 0表示缺口部分不运输相当于需求未被满足。但这里有个隐患如果成本为 0求解器可能会优先让虚拟仓库满足所有需求导致实际仓库的运输量为 0。为了避免这种情况通常给虚拟仓库到工地的成本设一个很大的惩罚值比如 9999迫使求解器优先使用真实仓库。在 GAMS 里实现虚拟节点有两种方式。一种是直接在集合里加元素Set i warehouses / north, west, dummy_wh / j markets / site1, site2, site3 /;然后给 dummy_wh 赋供应量和成本。另一种是保持集合不变在参数赋值时动态调整。我更推荐第一种因为结构清晰后面看代码一眼就能明白哪些是真实节点、哪些是虚拟节点。3.3 一个完整的非平衡运输问题实例假设现在有三个仓库供应量分别是 30、20、25 吨总供应 75 吨。两个工地需求量分别是 40、30 吨总需求 70 吨。总供应大于总需求 5 吨。引入虚拟工地 site_dummy需求量 5 吨所有仓库到它的成本为 0。Set i warehouses / wh1, wh2, wh3 / j markets / site1, site2, site_dummy /; Parameter supply(i) / wh1 30, wh2 20, wh3 25 / demand(j) / site1 40, site2 30, site_dummy 5 /; Table cost(i,j) site1 site2 site_dummy wh1 7 9 0 wh2 8 6 0 wh3 5 10 0;注意这里用了 Table 来定义二维参数比逐个赋值更直观。Table 的写法是第一行是列名对应 j 的元素第一列是行名对应 i 的元素中间填数值。这种写法在参数较多时特别方便。求解之后如果 site_dummy 从某个仓库接收了 5 吨说明那个仓库有 5 吨库存没有运出去。具体是哪个仓库取决于哪个仓库的剩余成本最低。这个结果本身也有业务含义它告诉你哪个仓库的库存应该优先保留。注意虚拟节点的成本设置直接影响求解结果。如果虚拟工地成本设为 0求解器会倾向于让运输成本最高的仓库留下库存如果设为一个较大的值求解器会尽量让所有真实仓库都出货虚拟节点只承担不得不剩余的部分。具体怎么设取决于你的业务目标。4. 求解结果怎么看从输出文件里读出业务洞察4.1 GAMS 输出文件的结构拆解很多人第一次跑完 GAMS看到满屏的输出就懵了。其实输出文件的结构很固定关键信息就那么几块。我通常按这个顺序看第一块是编译和执行信息包括模型有多少个方程、多少个变量、多少个非零元素。这些数字能帮你判断模型规模是否合理。如果方程数或变量数明显不对说明集合或参数定义有问题。第二块是求解器状态。最关键的是 SOLVER STATUS 和 MODEL STATUS。SOLVER STATUS 是 1 表示正常完成MODEL STATUS 是 1 表示找到最优解。如果这两个数字不是 1就要去查对应的错误代码。第三块是目标函数值通常显示为 OBJECTIVE VALUE。这是总运输成本是你最关心的数字之一。第四块是变量取值显示为 x(i,j) 的各个分量。这里能看到每条线路具体运了多少。如果某个变量是 0说明这条线路没有被使用。4.2 用 display 和参数报告把结果整理清楚GAMS 默认的输出比较冗长直接看变量取值列表不够直观。我习惯在 solve 语句后面加几行代码把结果整理成更易读的形式Parameter report(i,j) transportation plan; report(i,j) x.l(i,j); display report; display x.l;x.l 表示变量 x 在求解后的取值.l 是 level 的缩写。把 x.l 赋给 report 参数再用 display 打印输出会更干净。如果你想把结果导出到 Excel 或 CSV可以用 execute_unload 或者 put 语句这里不展开后面单独讲。4.3 从运输方案里读出业务含义求解结果不只是一堆数字它背后有业务含义。比如某个仓库到某个工地的运输量特别大说明这条线路的成本优势明显某个仓库的供应量没有被完全使用说明它的成本偏高或者位置不好某个工地的需求由多个仓库共同满足说明单一仓库无法覆盖它的需求。我在实际项目里通常会做两件事。第一把运输方案和成本结构对照看验证结果是否符合直觉。如果某个结果很反直觉比如成本最高的线路反而运得最多那就要检查模型是不是写错了。第二做敏感性分析看看如果某个仓库的供应量增加 10%或者某条线路的成本下降 10%总成本会怎么变。GAMS 做敏感性分析很方便改个参数重新 solve 就行。5. 那些年我踩过的 GAMS 坑从语法错误到模型不可行5.1 集合元素命名引发的血案GAMS 对集合元素的命名有一些隐含规则。比如元素名不能以数字开头不能包含特殊字符长度也有限制。我见过有人用 1号仓库 这种中文加数字的命名结果编译直接报错。还有人用 site-1 这种带连字符的名字GAMS 会把连字符当成减号处理导致解析错误。安全的做法是用纯英文字母和数字数字不要放在开头不要用特殊符号。如果实在需要中文注释可以写在集合声明后面的注释里或者用别名。5.2 方程定义里的维度不匹配这是新手最容易犯的错误之一。比如 supply_con(i).. sum(j, x(i,j)) l supply(i) 这行sum 里的 x(i,j) 是二维的sum 对 j 求和之后变成一维正好和 supply(i) 匹配。如果你不小心写成 sum(j, x(j,i))维度就对不上了GAMS 会报 dimension mismatch 错误。排查这类错误的方法先看方程左边的维度再看右边的维度确保两边一致。如果涉及 sum先确定 sum 掉哪个维度剩下的维度就是结果的维度。5.3 模型不可行的常见原因与排查路径模型不可行MODEL STATUS 4 或 5是另一个高频问题。运输问题里不可行通常有这几个原因第一总供应小于总需求且没有引入虚拟仓库。这是最常见的原因。检查方法把 supply 和 demand 分别求和比较大小。第二某个约束条件写反了。比如把 l 写成了 g导致供应约束变成了必须大于等于供应量而供应量是上限自然不可行。第三参数赋值有误。比如某个需求量为负数或者某个供应量为零但需求为正导致无解。第四虚拟节点的成本设置不合理。比如虚拟仓库到所有工地的成本都是 0但求解器仍然找不到可行解可能是因为虚拟仓库的供应量没有正确设置。排查路径先检查供需总量再检查约束方向最后检查参数赋值。如果还不行把模型简化到最小规模逐个恢复定位问题所在。5.4 求解器选择与性能调优的几点经验GAMS 支持多种线性规划求解器常见的有 CPLEX、Gurobi、XPRESS、MINOS 等。对于运输问题这种小规模线性规划任何求解器都能秒解。但如果模型规模扩大到几千个节点、几万条线路求解器的选择就重要了。我的经验是CPLEX 和 Gurobi 在大型线性规划上表现最稳XPRESS 也不错MINOS 更适合非线性问题。如果只是教学或小规模测试用默认求解器就行。如果遇到求解时间过长可以尝试调整求解器的参数比如设置最优性容差、开启预处理、调整单纯形法的定价策略等。但这些属于进阶内容新手先把模型写对、跑通再考虑调优。6. 把运输问题模型改造成你自己的业务工具6.1 从运输问题到生产排程的映射思路运输问题的结构是供应节点—需求节点—运输成本这个结构可以映射到很多业务场景。比如生产排程工厂是供应节点订单是需求节点生产成本加运输成本是单位成本排程问题就变成了运输问题。再比如人员分配员工是供应节点岗位是需求节点员工到岗位的适配成本是单位成本分配问题也变成了运输问题。关键是把业务里的资源和需求抽象成集合把匹配成本抽象成参数把分配量抽象成变量。一旦完成这个抽象剩下的就是套用运输问题的标准模型。6.2 用 GDX 文件管理数据让模型和业务数据解耦实际项目里数据通常来自 Excel、数据库或 CSV 文件而不是硬编码在 GAMS 代码里。GAMS 提供了 GDXGAMS Data Exchange格式来管理数据。你可以用 GDXXRW 工具把 Excel 数据读入 GDX再用 $GDXIN 语句在 GAMS 里加载。这样做的好处是模型代码和数据分离改数据不用改代码模型可以复用。比如你有 10 个仓库、20 个工地数据在 Excel 里维护GAMS 代码只需要写一次每次换数据重新加载就行。6.3 把求解结果写回 Excel 的完整流程求解完之后结果通常需要写回 Excel 供业务人员查看。GAMS 提供了几种方式用 execute_unload 把结果存成 GDX再用 GDXXRW 写回 Excel或者用 put 语句直接写文本文件再用 Excel 打开。我常用的是 put 语句因为它简单直接File results /transport_results.csv/; Put results; Put Warehouse,Market,Quantity, /; Loop((i,j), Put i.tl, ,, j.tl, ,, x.l(i,j), /; ); PutClose results;这段代码会把运输方案写成一个 CSV 文件每行是仓库,工地,运输量。i.tl 和 j.tl 是集合元素的文本标签。用 Loop 遍历所有组合把结果逐行写出。这种写法虽然原始但足够灵活适合快速出结果。6.4 模型扩展的几个方向多商品、多周期、带容量限制标准运输问题假设单一商品、单一周期、无容量限制。实际业务往往更复杂。几个常见的扩展方向多商品运输不同商品不能混装需要为每种商品单独定义变量和约束。GAMS 里可以加一个商品集合 k把 x(i,j) 扩展成 x(i,j,k)。多周期运输每个周期有独立的供需和成本需要加一个时间集合 t把 x(i,j) 扩展成 x(i,j,t)并增加库存平衡约束。带容量限制每条线路有最大运输量需要加一个约束 x(i,j) l capacity(i,j)。这个扩展最简单加一行方程就行。这些扩展在 GAMS 里都不难实现关键是先把标准模型吃透再逐步叠加复杂度。我建议新手不要一上来就写复杂模型先把两仓库三工地的标准问题跑通再慢慢加东西。7. 关于学习路径的一点个人体会我刚开始学 GAMS 的时候最大的困惑不是语法而是不知道模型能干什么。后来发现最好的学习方式不是啃手册而是找一个真实的小问题从头到尾做一遍。运输问题就是最好的起点它足够简单能在一天内跑通又足够典型涵盖了线性规划的核心概念。我的建议是先照着教程把标准运输问题敲一遍确保能跑出结果然后改数据改成你自己熟悉的场景比如快递网点调拨、食堂食材分配、图书馆图书调配再然后加约束比如某条线路有容量限制、某个仓库有最低出货量最后尝试把数据从代码里分离出来用 Excel 管理。走完这四步你对 GAMS 和线性规划的理解会完全不一样。另外不要怕报错。GAMS 的错误信息其实很详细关键是学会看。编译错误看行号和错误类型求解错误看 MODEL STATUS 和 SOLVER STATUS结果异常看参数赋值和约束方向。踩的坑越多后面写模型越快。最后分享一个小技巧在模型开头加一行option limrow 0, limcol 0;可以抑制输出文件里的方程和变量列表让输出更干净。这个设置在教学演示时特别有用能让你快速定位到关键结果。