简介:这份《开源求解器SCIP的Python接口学习手册》面向运筹学优化建模、混合整数规划以及Python科学计算的学习者与工程实践者,聚焦求解器Python接口的快速上手与日常查阅。内容围绕模型核心类展开,讲解初始化、对象比较、内存释放等成员方法的作用与调用方式,并系统梳理普通约束、逻辑与或约束、基数约束、指示器约束、特殊顺序集约束等多类约束的添加接口及参数含义,同时涉及Benders分解的激活与子问题添加等高级功能,便于读者理解建模流程与求解器扩展能力。资源包共1个PDF文件,约28.69MB,适合作为案头手册按需检索,也可结合实际优化模型对照练习。已有1820人学习,说明其在接口入门与查阅场景中具有一定参考价值,能帮助读者减少API摸索成本,建立从模型构建、约束表达到高级算法调用的整体认识。
1. 从两百多个方法名说起:PySCIPOpt 值不值得花时间啃
手里有个选址加分配的混合整数规划,几千个变量,用脚本拼 LP 文件丢给求解器,跑一整晚只看到日志在滚,不知道它卡在哪个节点;想在模型里补一句"至多开三个点",还得自己引辅助变量、写大 M。换成 python 写 PySCIPOpt 之后,这类约束直接落到addConsCardinality,求解中途还能读到节点数、对偶界和 LP 松弛解。
这份学习手册把 Model 类的两百多个方法按建模、参数、求解控制、回调四块摊开,本质上是把 SCIP 的能力翻译成一组 Python 调用。适合已经能写 Python 循环、对运筹学只有课本印象的从业者,也适合想把启发式脚本塞进严格求解框架的人。下面按"装起来能跑、约束怎么挑、参数怎么设、哪里会翻车"的顺序往下推。
2. 环境与建模骨架:从 Model 初始化到读到第一个可行解
拿到一份方法清单,最容易犯的错是从第一页开始顺序读,读完__dealloc__和__hash__就卡住了。这两个是 Python 层的对象协议,日常建模根本碰不到。真正决定你能不能跑通第一个模型的,是__init__的九个构造参数、addVar的六个入参、以及optimize之后取值的几条分支。这一章把这四件事按执行顺序串起来。
2.1 装 PySCIPOpt:二进制依赖和版本对齐是第一步
PySCIPOpt 不是纯 Python 包,它下面挂着 SCIP 的 C 库和 SoPlex 的 LP 求解器。用 pip 装的时候,如果本机没有匹配版本的动态库,import 阶段就会崩,报的错通常是一长串找不到符号,看着像玄学问题,其实是二进制没对齐。
我一般的做法是先上 conda-forge,它把 SCIP、SoPlex、IPOPT 这些依赖一次性编好,省掉编译环节:
# 独立环境,避免和已有的 scipy / 商业求解器依赖打架 conda create -n scipenv -c conda-forge pyscipopt python=3.11 -y conda activate scipenv # 如果已经有可用的 SCIP 动态库,纯 pip 也能走通 pip install pyscipopt装完先别急着建模,跑两行探针确认版本和对象都活着:
from pyscipopt import Model m = Model("probe") m.printVersion() # 打印 SCIP 本体版本与 PySCIPOpt 版本 print("vars:", m.getNVars()) # 空模型应为 0 print("conss:", m.getNConss())printVersion打印的是 SCIP 本体版本号,不是你 pip 里那个包的版本,两者对不上时排查问题会非常费劲。getNVars和getNConss在刚构造的 Model 上返回 0,如果这一步就报错,说明底层库没挂上,后面所有代码都白搭。手册里那些方法签名看着很全,但真正卡人的往往是这一层。
2.2 Model 构造参数:哪些默认值不要动
__init__的完整签名是(problemName='model', defaultPlugins=True, Model, sourceModel=None, origcopy=False, globalcopy=True, enablepricing=False, createscip=True, threadsafe=False)。九个参数里,日常建模只需要动第一个和第五个,其余几个乱改会直接把求解行为改掉。
| 参数 | 默认 | 什么时候需要动 |
|---|---|---|
problemName | 'model' | 多模型批量实验时改成可区分的名字,日志好 grep |
defaultPlugins | True | 只有在做插件替换实验时才关,关了要自己 include 全套 |
sourceModel | None | 从已有模型复制骨架,做多场景对比 |
origcopy | False | 配合sourceModel,复制 presolve 之前的原始问题 |
globalcopy | True | 控制全局参数是否跟着复制体走 |
enablepricing | False | 列生成场景,需要自己写 Pricer 才开 |
createscip | True | 延迟创建底层实例,一般不动 |
threadsafe | False | 多线程里共享模型时考虑 |
defaultPlugins=True这一条尤其别动。它背后挂的是 SCIP 默认的约束处理器、分离器、启发式、节点选择器一整套餐。关掉之后你得自己includeConshdlr、includeSepa、includeHeur把缺的补回来,少一个就会出现"模型建好了但求解器不认约束"的怪现象。
从已有模型复制骨架是这份手册里被低估的功能:
from pyscipopt import Model base = Model("base") x = base.addVar("x", vtype="C", lb=0, ub=10, obj=1.0) base.addCons(x <= 5, name="cap") base.setRealParam("limits/time", 30.0) # 复制体:origcopy 复制原始问题,globalcopy 把 limits/time 也带过来 clone = Model("clone", sourceModel=base, origcopy=True, globalcopy=True) # 关键:不要复用 base 里的 Variable 句柄,按名字从新模型里重新取 xv = clone.getVars()[0] y = clone.addVar("y", vtype="B", obj=2.0) clone.setObjective(xv + 2.0 * y, sense="minimize")origcopy=True复制的是未经 presolve 转换的原始问题,适合"同一套约束骨架、换几组需求数据"的批量实验。globalcopy=True会把你之前在 base 上设过的参数一起带过去,这一点常被忽略:你在 base 上设了limits/time=30,复制体也会继承,跑批量的时候每个子模型都被 30 秒掐死,还以为是模型本身难解。跨模型复用变量句柄则是另一个雷,base的 Variable 拿去给clone用会直接抛异常,必须按名字或下标从新模型里取。
2.3 变量与目标的写法:addVar 的四个关键入参
addVar(name='', vtype='C', lb=0.0, ub=None, obj=0.0, pricedVar=False)这六个参数里,vtype、lb、ub、obj是每天都要用的。vtype取'C'连续、'B'二元、'I'整数、'M'隐式整数;ub=None表示用 SCIP 内部的无穷大,不是 Python 的 None 传下去就完事,它会被翻译成m.infinity()。
一个能跑通的骨架比十页方法说明管用,下面这个是小规模设施选址加分配的完整模型:
from pyscipopt import Model, quicksum m = Model("demo_plan") sites = range(3) # 候选点 dems = range(4) # 需求点 cap = {0: 40, 1: 30, 2: 50} # 每个点的容量 need = {0: 12, 1: 18, 2: 9, 3: 21} fix = {0: 8.0, 1: 6.5, 2: 9.0} # 开启成本 open_ = {i: m.addVar(f"open_{i}", vtype="B", obj=fix[i]) for i in sites} ship = {(i, j): m.addVar(f"ship_{i}_{j}", vtype="C", lb=0.0, ub=None) for i in sites for j in dems} # 需求满足 for j in dems: m.addCons(quicksum(ship[i, j] for i in sites) >= need[j], name=f"dem_{j}") # 容量与开启联动:没开的点不允许出货 for i in sites: m.addCons(quicksum(ship[i, j] for j in dems) <= cap[i] * open_[i], name=f"cap_{i}") m.setMinimize() m.optimize()quicksum不是语法糖那么简单,它在累加过程中保持表达式结构,长表达式不会像内置sum那样反复创建中间对象,几百个变量以上差别就明显了。obj参数只接受线性目标系数,如果要写二次或者带常数项的目标,得用setObjective传完整表达式,再用addObjoffset单独加常数。容量联动那条用的是大 M 的简化版,cap[i] * open_[i]已经是最紧的 M,不用另外估一个大数,M 估大了 LP 松弛会松得离谱,这是后面避坑章要展开的点。
2.4 求解与取值:getStatus 之后的四条分支
optimize()一行下去,返回值不是重点,重点是之后读getStatus()。它返回的是字符串,常见值有optimal、infeasible、unbounded、timelimit、nodelimit、unknown。很多人跑完直接getObjVal(),碰到 timelimit 时拿到的是当前最好可行解的目标值,如果连一个可行解都没有,这行会抛异常。
status = m.getStatus() print("status:", status, "gap:", m.getGap()) if status == "optimal": obj = m.getObjVal() # 原始问题的目标值 sol = m.getBestSol() picked = [v.name for v in m.getVars() if v.vtype() == "B" and m.getSolVal(sol, v) > 0.5] flow = {(v.name): round(m.getSolVal(sol, v), 3) for v in m.getVars() if v.vtype() == "C" and m.getSolVal(sol, v) > 1e-6} elif status in ("timelimit", "nodelimit"): # 有时间限制时先确认有没有可行解,再决定要不要读值 if m.getNSols() > 0: print("最好可行解:", m.getObjVal(), "对偶界:", m.getDualbound()) else: print("无解或未收敛,先去看日志")getObjVal(original=True)返回的是原始问题目标,已经把objoffset算进去了;getBestSol配合getSolVal可以按解对象逐变量取值,多个可行解并存时这条路径更稳。getVal只对当前最佳解有效,一旦你在求解后又加了约束、动了变量界,这个值就不一定还对应原来那个解了。getNSols和getNSolsFound的区别是前者是当前保留的解池大小,后者是历史上找到过的解总数,判断"到底有没有解"看前者。
到这一步,一个能建、能解、能取值的闭环就跑通了。剩下的两百多个方法,绝大部分是围绕这个闭环做增补:加更复杂的约束、控求解过程、在求解器内部挂钩子。
3. 约束族谱:addCons 系列怎么挑、属性开关怎么定
约束这一块是 PySCIPOpt 里最容易写错的地方,不是因为 API 复杂,而是因为同一个语义有好几种写法,选错了模型能解但解得很慢,甚至解出不符合预期的结果。手册里列了addCons、addConsAnd、addConsOr、addConsXor、addConsIndicator、addConsCardinality、addConsSOS1、addConsSOS2、addConsCoeff、addConsLocal、addConsNode、addConss一长串,实际用的时候要按"这个约束交给谁处理"来分类。
3.1 通用约束与批量添加:addCons 与 addConss
addCons是最通用的入口,接受任何表达式或者约束对象,后面跟九个布尔属性。绝大多数线性约束都走这条路:
from pyscipopt import Model, quicksum m = Model("cons_demo") x = {i: m.addVar(f"x{i}", vtype="C", lb=0, ub=10) for i in range(5)} # 单条添加,显式命名,方便后面 getConss 后按名字定位 c1 = m.addCons(quicksum(x[i] for i in range(5)) <= 20, name="budget") # 批量添加:一次传一组,共享同一套属性 batch = [x[i] + x[i + 1] <= 15 for i in range(4)] m.addConss(batch, name="adj", initial=True, separate=True) for c in m.getConss(): print(c.name, m.getLhs(c), m.getRhs(c))批量添加的价值在于属性只写一次。如果你有五十条同类约束,全都需要modifiable=False、removable=True,用addConss比循环调addCons少写五十遍参数,也少五十次属性解析。getLhs和getRhs拿的是约束的左右端,配合getValsLinear可以把一条约束的系数还原成字典,做模型审计时很实用。
addConsCoeff是另一个层次的工具:它改的是已有约束里的单个系数,而不是新增约束。用它的前提是那条约束在创建时modifiable=True,否则求解器会拒绝修改。这个开关很多人在建约束时随手设成默认的 False,等到要做列生成或者动态改系数时才发现改不动,只能重建模型。
3.2 逻辑约束:And / Or / Xor / Indicator 的落地写法
这四个方法处理的是布尔关系,直接手写大 M 也能表达,但求解器认不出结构,传播和分离都用不上,白白丢掉一堆剪枝。手册里的签名是这样的:addConsAnd(vars, resvar, ...)表示所有vars为真时resvar为真,addConsOr表示至少一个为真,addConsXor(vars, rhsvar, ...)表示奇数个为真。
from pyscipopt import Model m = Model("logic_demo") a = m.addVar("a", vtype="B") b = m.addVar("b", vtype="B") c = m.addVar("c", vtype="B") r = m.addVar("r", vtype="B") # r <-> (a AND b) m.addConsAnd([a, b], r, name="and_ab") # 至少一个开启 m.addConsOr([a, b, c], r, name="or_abc") # 三者中奇数个为真 m.addConsXor([a, b, c], r, name="xor_abc")指示约束走的是另一条路,addConsIndicator(cons, binvar=None, activeone=True, ...)把一个线性约束挂在二元变量上:binvar取activeone指定的那个值时,cons才生效。它比手写大 M 好在 M 是由求解器自己推的,不用你估。
提示:逻辑约束的变量必须都是二元变量,传连续变量进去不会报错但行为未定义,这类问题排查起来非常费时间。
用逻辑约束的收益主要在 presolve 阶段。求解器能识别出x AND y这种结构,做变量固定和冲突检测;手写的大 M 在它眼里就是一条普通线性不等式,什么也做不了。所以凡是语义上明确是逻辑关系的,优先用对应的addCons*方法,别自己拼。
3.3 基数约束与 SOS:把"至多选 k 个"交给求解器
"从十个候选里至多选三个"是最常见的组合约束。手写的方式是加一条sum(z) <= 3,这已经是最简形式了,但如果还带权重或者指示变量,addConsCardinality就更合适:
from pyscipopt import Model m = Model("card_demo") z = [m.addVar(f"z{i}", vtype="B") for i in range(10)] w = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3] # 至多选 3 个 m.addConsCardinality(z, 3, name="at_most_3") # 带权版本:选中的权重之和不超过 12,indvars 用来承接计数结果 ind = [m.addVar(f"i{i}", vtype="B") for i in range(10)] m.addConsCardinality(z, 12, indvars=ind, weights=w, name="weight_cap")SOS1 和 SOS2 是另一类特殊结构。SOS1 表示一组变量里至多一个非零,SOS2 表示至多两个相邻的非零,常见于分段线性函数的建模。它们的价值在于求解器可以对这组变量做专门的分支,比通用整数约束快得多:
# 一组变量里至多一个非零 segs = [m.addVar(f"s{i}", vtype="C", lb=0, ub=1) for i in range(6)] m.addConsSOS1(segs, weights=[0, 1, 2, 3, 4, 5], name="sos1_demo") # 相邻两个非零,用于分段线性近似 m.addConsSOS2(segs, weights=[0, 1, 2, 3, 4, 5], name="sos2_demo")weights不是可有可无的装饰,它决定了求解器怎么排序这组变量、怎么判断"相邻"。不传的话默认按下标顺序,如果你的分段点本身不均匀,一定要显式传,否则 SOS2 的相邻关系和你的业务语义对不上,解出来的插值点是错的。
3.4 九个布尔属性:modifiable 与 removable 别乱开
addCons后面那串initial / separate / enforce / check / propagate / local / modifiable / dynamic / removable / stickingatnode不是摆设,每一个都对应求解器内部的一条处理路径:
| 属性 | 作用 | 乱设的后果 |
|---|---|---|
initial | 是否进入初始 LP | 设 False 会推迟到分离阶段才加,可能多绕几轮 |
separate | 是否允许被分离器加回 | 设 False 后违反该约束的解可能不被割掉 |
enforce | 是否参与约束强制 | 设 False 会跳过 LP 解不满足时的处理 |
check | 解是否要检查该约束 | 设 False 后checkSol查不出违反 |
propagate | 是否参与域传播 | 设 False 丢掉一条剪枝途径 |
local | 只对当前节点有效 | 用在根节点上等于白写,节点一换就失效 |
modifiable | 允许求解中改系数 | 不设 True 就不能用addConsCoeff |
dynamic | 约束可被删除后重加 | 一般配合分离器使用 |
removable | 不需要时从问题中移除 | 设 False 会让模型越来越大 |
实际建模里九成约束用默认值就够。真正需要动的是三类场景:列生成要modifiable=True;分支切割框架里动态添加的割要removable=True或dynamic=True;节点局部约束要local=True。其余属性在没有自定义约束处理器之前,改了只会让求解行为变得难以解释。
4. 参数与求解控制:setParam 家族和状态诊断
模型建好之后跑得慢,八成不是模型写错了,而是参数没调。SCIP 有上千个参数,全都挂在setParam这套接口下面。手册里列出了setBoolParam、setIntParam、setLongintParam、setRealParam、setCharParam、setStringParam、setParam、getParam、getParams、readParams、resetParam、resetParams这一组,用起来像字典,但类型必须对得上。
4.1 setParam 家族:五类参数别写错类型
setParam(name, value)是通用入口,它内部会去查参数表推断类型,推不出来就报错。更稳的写法是按类型走专用方法:
from pyscipopt import Model m = Model("param_demo") m.addVar("x", vtype="C", lb=0, ub=10) m.setRealParam("limits/time", 60.0) # 实数:时间上限,单位秒 m.setIntParam("limits/nodes", 5000) # 整数:节点数上限 m.setBoolParam("misc/allowstrongbranch", False) # 布尔:关掉强分支 m.setStringParam("limits/solutions", "1") # 字符串参数,注意值要转成 str m.setLongintParam("limits/gap", 1e-4) # 长整型/浮点混合,用于 gap 阈值 print(m.getParam("limits/time")) # 读回当前值 m.resetParam("limits/nodes") # 单个重置 m.resetParams() # 全部重置成默认最容易踩的是limits/gap,它本质是个浮点阈值但走的是 longint 通道,传 0.05 这种小数在某些版本里会被截断,稳妥做法是用setRealParam或者传整数倍的 1e-4。另一个坑是limits/solutions这种参数,它其实接受数字,但类型登记成字符串,setIntParam会直接抛类型错误,报错信息里只有参数名没有正确类型,第一次碰到要愣一会儿。
参数文件是批量实验的好帮手,readParams("xxx.set")直接吃 SCIP 原生的.set格式,这个格式是纯文本,一行一个name value,可以用脚本批量生成不同配置:
# 用代码批量生成配置,再读进去 configs = { "fast.set": ["limits/time 30", "limits/gap 0.01", "presolving/maxrounds 3"], "tight.set": ["limits/time 600", "limits/gap 1e-6", "presolving/maxrounds -1"], } for fname, lines in configs.items(): with open(fname, "w") as f: f.write("\n".join(lines)) m.readParams("fast.set")presolving/maxrounds的-1表示不限轮数,0表示完全关掉 presolve。关 presolve 通常是为了保留原始结构做调试,代价是求解时间可能翻几倍,别在正式跑批的时候留着。
4.2 阶段判定:presolve 和 optimize 的调用顺序
SCIP 是个有状态机的东西,getStage()返回当前所处的阶段。PROBLEM阶段可以自由加变量约束,PRESOLVED之后原始问题已经被转换,再去改就要注意用getTransformedCons做映射,SOLVED阶段只能读结果,想改必须freeTransform退回去。
from pyscipopt import Model m = Model("stage_demo") x = m.addVar("x", vtype="C", lb=0, ub=10, obj=1.0) m.addCons(x >= 2, name="lb2") print("建模后:", m.getStage()) # PROBLEM m.presolve() print("预处理后:", m.getStage()) # PRESOLVED m.optimize() print("求解后:", m.getStage()) # SOLVED # 想继续改模型,先退回去 m.freeTransform() print("退回后:", m.getStage()) # PROBLEM很多人写循环求解时会连续调两次optimize(),第二次之前没做任何处理,求解器会认为模型没变直接返回上次结果,看起来像"改了参数不生效"。正确做法是改参数不受阶段限制,但改模型结构必须先freeTransform。presolve()单独调用的场景不多,主要是想在真正求解前看一眼预处理后的规模,getNVars()在 presolve 前后的差异能告诉你多少变量被固定掉了。
4.3 读统计量定位问题:gap、节点数、LP 迭代
跑完一次求解,日志信息量很大但不好读,直接从模型对象上取统计量更直接:
print("状态:", m.getStatus()) print("原始界:", m.getPrimalbound(), "对偶界:", m.getDualbound(), "gap:", m.getGap()) print("节点数:", m.getNNodes(), "总节点:", m.getNTotalNodes()) print("LP 迭代:", m.getNLPIterations(), "LP 次数:", m.getNLPs()) print("割数:", m.getNCuts(), "已应用:", m.getNCutsApplied()) print("求解时间:", m.getSolvingTime(), "总时间:", m.getTotalTime()) print("预处理时间:", m.getPresolvingTime(), "读取时间:", m.getReadingTime())判断模型难在哪里,看这几个数的组合:gap大且节点数增长快,说明分支定界树爆炸,通常是 LP 松弛太松或者对称性太强;gap大但节点数很少,说明卡在根节点的启发式或者割平面生成上;NLPIterations/NLPs的比值特别高,说明 LP 本身数值条件差,得回去查系数数量级。getNCuts和getNCutsApplied的差距能反映割的接受率,接受率极低说明分离器生成的割质量不行,可以考虑关掉某些分离器。
getCondition()返回的是 LP 基矩阵条件数,这个值超过 1e10 基本可以判定数值不稳定,先把变量和约束的系数量级统一到 1 附近再说。
4.4 日志与输出:hideOutput、setLogfile 与 redirectOutput
批量跑模型的时候终端会被日志刷屏,三种处理方式对应三种场景:
m.hideOutput() # 完全静默,适合大批量扫参 m.setLogfile("run_001.log") # 日志落到文件,适合事后复盘 m.redirectOutput() # 把 C 层输出重定向到 Python,适合做进度解析hideOutput()是最省事的,但它连错误信息一起吞掉,调试阶段别用。setLogfile写出来的是 SCIP 原生格式,包含每个节点的完整信息,事后用 grep 找"某个变量在哪一步被固定"很好用。redirectOutput()比较特殊,它把底层 C 的输出接到 Python 的 stdout 上,能在 Jupyter 里看到完整的求解日志,但输出量大时会拖慢求解速度,跑长任务记得关。
5. 避坑排查:六条踩过的坑
这一章不是 API 说明,是实际跑模型时反复撞上的问题。每条按"看到什么现象、根因在哪、怎么改"来写,能对上号就直接抄改法。
5.1 建模阶段的三个坑
坑一:大 M 估大了,模型能解但解不动。
现象是模型规模不大,变量几百个,但 LP 松弛解离整数解很远,gap 从根节点就卡在 30% 以上,节点数一路涨。
根因在大 M 的取值。手写y <= M * z时习惯性给M = 1e6,这会让 LP 松弛里z取 0.001 就能让y拿到 1000 的空间,松弛形同虚设。
解决方式是让 M 取业务上真正可能的最大值。上面设施选址那个例子里,cap[i] * open_[i]就是最紧的 M,因为第 i 个点的出货量本身不会超过它的容量。如果业务上界不好推,用addConsIndicator把约束挂到二元变量上,让求解器自己推 M。
坑二:跨模型复用变量句柄,报的错跟变量毫无关系。
现象是从base模型复制出clone之后,想拿原来的x直接加进clone,抛出来的异常说对象类型不对,甚至有时候不报错但结果完全不对。
根因是 PySCIPOpt 的 Variable 对象绑定在创建它的 Model 上,跨模型使用在 C 层拿不到对应的指针。
解决办法是复制之后一律用clone.getVars()按名字或下标重新取,或者干脆在复制前就把变量名规范好,复制后写一个{v.name: v for v in clone.getVars()}的字典,后面全按名字索引。这个习惯我后来在所有多模型脚本里都强制用。
坑三:约束属性随手设 False,求解器直接"看不见"这条约束。
现象是模型里明明加了约束,解出来的结果却不满足它,checkSol也不报错。
根因是check=False或者enforce=False。这两个开关一关,求解器在对应环节就跳过这条约束,解自然可能违反。
解决办法很简单,除非在做自定义约束处理器,check和enforce永远保持默认的 True。验收阶段养成习惯,每次求解完跑一次m.checkSol(m.getBestSol(), printreason=True),它能查出哪条约束被违反、违反了多少。
5.2 求解与资源阶段的三个坑
坑四:设了时间限制,但实际跑了两倍时间才停。
现象是limits/time设了 60 秒,实际墙钟时间跑了 120 秒以上。
根因是limits/time计的是求解器内部时间,不包括建模、读文件、presolve 之前的准备时间,也不包括某些插件的额外开销。另外在多线程或者回调里做重活时,主循环感知不到时间流逝。
解决办法是外层再包一层墙钟监控,用interruptSolve()从另一个线程打断:
import threading from pyscipopt import Model m = Model("timeout_demo") m.setRealParam("limits/time", 60.0) def killer(): threading.Event().wait(75) # 比内部限制多留 15 秒缓冲 m.interruptSolve() threading.Thread(target=killer, daemon=True).start() m.optimize()interruptSolve是线程安全的,但threadsafe=False的模型在多线程里做别的操作不安全,所以监控线程里只调这一个方法,别顺手去读getGap()。
坑五:反复求解不用 freeProb,内存一路涨到 OOM。
现象是批量跑几百个模型,跑到一半进程被系统杀掉,看内存曲线是单调上升。
根因是每个 Model 对象在 C 层都持有一块 problem 内存,Python 的垃圾回收管不到它,循环里创建新 Model 而不释放,前一个就一直挂着。
解决办法是每个模型用完显式释放:
for cfg in configs: m = Model(f"run_{cfg}") # ... 建模与求解 ... m.freeProb() # 释放 C 层 problem 内存 del mfreeProb()释放的是底层 problem,Python 对象本身还要等 GC,两个一起做最干净。如果同一批实验共用骨架,更好的做法是复制一个 base 模型反复 reset,而不是每次从零建。
坑六:改了参数没生效,因为参数名拼错但没报错。
现象是设了"limits/time"之外的某个参数,跑完发现行为没变,getParam读回来还是默认值。
根因是setParam对未知参数的处理在不同版本里不一致,有些版本静默忽略,有些抛异常。而参数名本身是分层的,少写一层前缀就会落到错误的组里。
解决办法是设完立刻读回来校验:
def safe_set(m, name, value, setter): setter(name, value) got = m.getParam(name) if got != value: raise RuntimeError(f"参数 {name} 设置失败: 期望 {value}, 实际 {got}")这个包装我用了很久,代价是每次多一次读操作,收益是把"参数设了但没生效"这类问题挡在跑批之前。getParams()可以一次性把全部参数导出来对照,排查拼写错误时比逐个试快。
6. 三个自检动作:让模型问题在跑批之前暴露
前五章讲的是怎么把模型建起来、解出来。实际工作中真正省时间的是求解之后的复核环节,因为一个悄悄错了的模型,比一个跑不动的模型危险得多。我自己固定跑三个自检动作,每个都不超过十行代码。
第一个动作是解可行性复核。checkSol会把解代回所有约束,逐条报告违反量,completely=True时连范围检查和整数性检查一起做:
sol = m.getBestSol() ok = m.checkSol(sol, printreason=True, completely=True) print("可行性:", ok)第二个动作是目标值三处对齐,把求解器报的值、按解对象重算的值、表达式求值的结果放在一起看:
| 来源 | 调用 | 用途 |
|---|---|---|
| 求解器报告 | m.getObjVal() | 对外汇报的数值 |
| 解对象 | m.getSolObjVal(sol) | 多解场景下逐解核对 |
| 表达式重算 | m.getSolVal(sol, m.getObjective()) | 检验目标表达式没写错 |
print(m.getObjVal(), m.getSolObjVal(sol), m.getSolVal(sol, m.getObjective()))三个数对不上,八成是objoffset没处理,或者建变量时obj参数和setObjective重复设置了同一批系数。这个坑很隐蔽,因为模型照样能解,只是目标值偏了一个常数。
第三个动作是参数敏感性扫描。同一份模型,把limits/time从 5 秒二分到 300 秒,记录每个时间点下的gap和getDualbound(),画出收敛曲线。如果 30 秒和 300 秒的 gap 只差一两个百分点,说明这个模型的上限就在这,再给时间也没用,该回去改模型而不是加机器。
有一回我图省事跳过了第一个动作,直接拿跑批结果做汇报,第二天被指出某个需求点的分配量是负的。回去查发现是漏了一条lb=0,Python 默认的addVar确实给 0 下界,但那批变量是从一个旧脚本复制过来的,构造时显式写了lb=None。从那以后我每次跑批之前都强制走一遍checkSol,哪怕模型再简单,这一行不省。希望帮到你。
本文还有配套的精品资源,点击获取