简介:这份《开源求解器SCIP的python接口学习手册》面向运筹学、混合整数规划与优化算法学习者,以及需要用Python调用SCIP建模求解的开发者,帮助快速熟悉PySCIPOpt的API与核心对象。资源为1个PDF文件,压缩包约28.69MB,内容以接口参考和分类条目为主,便于按Model类、约束添加方法及Benders相关方法检索。手册重点覆盖Model初始化、对象比较与内存释放,addCons、addConsAnd、addConsCardinality、addConsIndicator、addConsSOS1等约束接口,以及activateBenders、addBendersSubproblem等分解求解入口,并解释常用参数含义。已有1820人学习,适合作为查阅型资料,配合示例练习可加深对建模流程、约束类型和求解器调用方式的理解。
1. 为什么用 PySCIPOpt 而不是手写分支定界:从一次调度模型卡死说起
去年冬天我接了一个车间排产的模拟项目,十二条产线、两百多个工单,换型时间还带顺序依赖,手写的分支定界跑了十四个小时连个可行解都没吐出来。后来把模型拆干净换成 PySCIPOpt,同样是 Python,不到两百行建模代码,十分钟出了可行解,gap 压到 1% 以内。这套 SCIP 的 Python 接口不是玩具——它把 LP 松弛、割平面、分支策略、原始启发式这些运筹学里的硬骨头全封装好了,你只需要把业务约束翻译成 addVar 和 addCons 两件事。适合谁:已经能用 Python 写循环、但被 PuLP/CBC 的求解速度卡住,或者需要写逻辑约束、SOS 约束、自定义分支规则的从业者。这份手册的价值在于把 Model 类那一百多个方法按建模顺序排开,告诉你哪些参数该动、哪些坑必须绕。
2. 建模骨架:Model 构造参数与 addVar/addCons/setObjective 的落地顺序
2.1 Model() 的构造参数:哪些该动、哪些保持默认
先把__init__的全签名摆出来,这是后面所有行为的起点:
Model(problemName='model', defaultPlugins=True, Model=None, sourceModel=None, origcopy=False, globalcopy=True, enablepricing=False, createscip=True, threadsafe=False)problemName只是标签,日志头、writeProblem的默认文件名会用到它。我一般给个业务相关的短名,比如shift_sched,后面跑批量实验时一眼能从日志里分清是哪个场景。defaultPlugins=True会加载 SCIP 自带的全套插件——约束处理器、分支规则、启发式、割平面分离器、预处理器。除非你在做算法研究要精简插件集做对照,一律保持 True;关掉之后addCons的很多行为会变成静默无效,这是新手最容易翻车的地方。
sourceModel、origcopy、globalcopy三个参数配套使用,用于从一个已有 Model 复制出新模型。origcopy=True复制的是原始(未变换)问题,globalcopy=True复制全局信息。做 scenario 批量求解时挺方便,但复制出来的是深拷贝,内存直接翻倍,节点数上到十万级要留意 RSS。enablepricing是列生成模式的开关,配合includePricer用,普通 MIP 建模保持 False。createscip=False只构造 Python 包装层、不创建底层 SCIP 实例,用于把已有 C 侧指针塞进来,纯 Python 建模用不到。threadsafe=True内部加锁,允许多线程调同一个 Model 做并发求解,代价是单线程性能有损耗,别默认开。
一个我常用的初始化模板:
from pyscipopt import Model, quicksum m = Model("shift_sched") # 问题标签:日志与导出都靠它区分场景 m.hideOutput() # 生产环境先关输出,调试时再打开 m.setRealParam("limits/time", 120.0) # 墙钟时间上限,单位秒 m.setRealParam("limits/gap", 0.01) # 相对 gap 到 1% 即停limits/time是墙钟秒数而不是 CPU 秒,容器里被限流时会提前触发;limits/gap是 |primal−dual|/|primal| 的相对值,注意分母是最优解绝对值,目标值接近 0 时这个比值会失真,那种场景改用limits/absgap。hideOutput()等价于把显示频率调到 0,不影响日志文件写入。
2.2 变量与目标的落盘顺序
addVar的签名是addVar(name='', vtype='C', lb=0.0, ub=None, obj=0.0, pricedVar=False)。vtype四个取值:'C'连续、'I'整数、'B'二元、'M'隐式整数(由 SCIP 内部决定是否当整数处理)。这里有个必须刻进肌肉记忆的点:addVar 返回的是 Variable 对象,不是整数索引。后面所有约束、取值、改界都用这个对象;别拿它跟 int 比较,也别指望m.getVars()[i]的顺序和创建顺序一致。
一个排产模型的骨架长这样:
from pyscipopt import Model, quicksum m = Model("job_shop") jobs, machines = range(6), range(3) horizon = 480.0 # x[j,k]=1 表示工单 j 分配到机器 k;obj 直接带上换型成本 x = {} for j in jobs: for k in machines: x[j, k] = m.addVar(vtype="B", name=f"x_{j}_{k}", obj=setup_cost[j][k]) # start[j] 是连续变量,工单开工时刻,上界显式给出 start = {j: m.addVar(lb=0.0, ub=horizon, vtype="C", name=f"start_{j}") for j in jobs} # 目标只在这里设一次:换型成本 + 延迟惩罚 m.setObjective( quicksum(x[j, k] * setup_cost[j][k] for j in jobs for k in machines) + quicksum(delay_w[j] * start[j] for j in jobs), sense="minimize", )变量创建顺序决定了 SCIP 内部列的顺序,但不保证getVars()的返回顺序,这点在写跨模型对比脚本时特别容易踩。目标函数必须在optimize之前设定,sense默认minimize,要最大化写sense="maximize"。addVar里的obj参数和setObjective二选一,两边都写会叠加——目标值翻倍就是这么来的,查半天查不出原因。ub=None表示 +infinity,如果业务上有一个明确上界,永远显式给出来,这是给预求解器最省事的礼物。
2.3 约束表达式与 addCons 的语义开关
PySCIPOpt 重载了+ - * <= >= ==,整条线性表达式可以直接当约束传进去:
# 每个工单恰好分配一台机器 for j in jobs: m.addCons(quicksum(x[j, k] for k in machines) == 1, name=f"assign_{j}") # 大 M 形式的析取约束:工单 1 要么在工单 0 之后,要么在前 M = horizon y = m.addVar(vtype="B", name="y_0_before_1") m.addCons(start[1] >= start[0] + dur[0] - M * (1 - y), name="disj_01_a") m.addCons(start[0] >= start[1] + dur[1] - M * y, name="disj_01_b")非线性项(两个变量相乘)也能直接写,会变成二次约束,SCIP 有二次约束处理器,小规模够用;规模上去后建议手动线性化,二次约束的分离开销不是线性增长的。
addCons后面那一串布尔开关才是真正拉开差距的地方:
| 参数 | 默认 | 什么时候动它 |
|---|---|---|
| initial | True | False 表示不进初始 LP,只在需要时由分离器加 |
| separate | True | 关掉可阻止 SCIP 对该约束做割平面分离 |
| enforce | True | 约束的 enforcement 分支,逻辑约束必开 |
| check | True | 求解后做可行性检查,调试期保持 True |
| propagate | True | 参与约束传播,逻辑约束必开 |
| local | False | True 表示只在当前节点有效 |
| modifiable | False | True 允许后续 addConsCoeff 改系数 |
| dynamic | False | True 允许求解中删除,省内存 |
| removable | False | True 表示约束可在节点间移除 |
| stickingatnode | False | True 时约束固定在节点上,不进全局池 |
普通线性约束全部默认就行。只有三类例外值得动它:逻辑约束(OR/AND/XOR/indicator)必须把enforce和propagate保持 True,否则逻辑关系会退化成一条松弛的线性约束,解出来看着可行、实际业务不成立;后续要改系数的大 M 约束提前设modifiable=True,不然addConsCoeff直接抛异常;滚动时域求解里挂在节点上的割设local=True。local=True有个副作用:optimize结束后getConss()会把它们也列出来,统计约束数时要自己区分全局和局部。
3. 逻辑约束与特殊约束:AND/OR/XOR 与指标约束的选型
3.1 从 if-then 到 addConsIndicator
业务里最常见的"如果选了这条产线,就至少要跑满两个小时",手写就是 big-M:start[j] >= t0 - M*(1-x[j,k])。问题在于 M 给大了 LP 松弛松得没边,给小了直接切掉可行解,这个值是玄学。PySCIPOpt 给了 indicator 约束绕开 M:
# x[j,k]=1 时,start[j] >= t0 必须成立 m.addConsIndicator(start[j] >= t0, binvar=x[j, k], activeone=True, name=f"ind_{j}_{k}")addConsIndicator(cons, binvar=None, activeone=True, ...)的语义很直白:binvar取activeone指定的那个值时,线性约束cons生效。activeone=False就是反过来,二元变量为 0 时约束成立。底层走的是 SCIP 的 indicator 约束处理器,它会自己在分支时优先处理这些二元变量,比 big-M 的松弛紧得多。
逻辑组合四个方法,参数签名高度一致:
# resvar = AND(vars):全部为 1 时 resvar 才为 1 m.addConsAnd([x[0, 0], x[1, 0]], resvar=z_and, name="and_00_10") # resvar = OR(vars):任一为 1 则 resvar 为 1 m.addConsOr([x[0, 0], x[1, 0]], resvar=z_or, name="or_00_10") # rhsvar = XOR(vars):vars 中为 1 的个数奇偶性等于 rhsvar m.addConsXor([x[0, 0], x[1, 0], x[2, 0]], rhsvar=z_xor, name="xor_col0")这三个方法返回的是 Constraint 对象,可以留着后面chgLhs、chgRhs改。要注意vars必须是 Variable 对象的 list,传 tuple 在某些版本里不报错但行为不对,统一用 list 最稳。XOR 的语义容易被误解成"恰好一个为 1",实际是奇偶性,要写"恰好一个"用addConsCardinality更直接。
3.2 基数约束与 SOS:两个被低估的建模工具
基数约束在排产里就是"一台机器最多接三个工单":
# sum(consvars) <= cardval m.addConsCardinality([x[j, k] for j in jobs], cardval=3, name=f"cap_machine_{k}")addConsCardinality(consvars, cardval, indvars=None, weights=None, ...)不带indvars时就是标准的sum(consvars) <= cardval;带上indvars变成 indicator 版本,只有indvars里对应位置为 1 的那些consvars参与计数。weights给每个变量加权,变成背包形式。这个方法内部会调用 SCIP 的基数约束处理器,在预求解阶段就能做系数收紧,比手写一堆线性约束快。
SOS1 和 SOS2 是分段线性建模的主力,很多做运筹的人反而忘了它们:
# SOS1:这一组变量里至多一个非零 m.addConsSOS1([flow[p, t] for t in periods], name=f"sos1_p_{p}") # SOS2:至多两个相邻变量非零,用来表达分段线性成本 m.addConsSOS2([seg[p, s] for s in segments], name=f"sos2_p_{p}")SOS1 适合"多选一但选中的那个可以取任意值"的场景,比如从几个供应商里选一个、选中的供货量连续。SOS2 适合凸分段线性函数的精确表达——成本曲线分五段,用五个段变量加一个 SOS2 约束比手写五个 big-M 二元变量干净得多,分支时 SOS2 处理器会直接在相邻段之间做分支,节点数通常能少一个量级。注意 SOS 约束必须给weights或者保持默认的顺序,顺序决定了"相邻"的定义,建变量时按段号顺序 add 进去就行。
3.3 addConss 批量添加与命名规范
模型大了以后逐个addCons会拖慢构建,addConss(conss, name='', ...)接受一个约束列表一次性提交:
batch = [] for k in machines: for j in jobs: batch.append(quicksum(x[j, kk] for kk in machines) <= cap[k]) conss = m.addConss(batch, name="machine_cap")返回的conss是一个列表,顺序和传入顺序一致,后面要按索引改约束就靠这个列表。命名上我有一条硬规矩:名字里必须带业务主键。cap_machine_2、disj_01_a、ind_3_1这种,出问题的时候能从日志里直接定位到是哪条业务规则,比c1、c2强一百倍。SCIP 允许重名,重名不会报错但导出 LP 文件后会分不清,getConss()里也没法按名字过滤。
4. 求解与取解:optimize 之后你该读哪些量
4.1 求解状态机:getStatus 的七种返回
optimize()是同步阻塞调用,返回之后第一件事是看状态:
from pyscipopt import SCIP_STATUS m.optimize() status = m.getStatus()返回值是SCIP_STATUS枚举,业务代码里真正要分支处理的是这几类:
| 状态 | 含义 | 处理方式 |
|---|---|---|
| OPTIMAL | 找到最优解 | 直接取解 |
| INFEASIBLE | 证明不可行 | 查约束矛盾,不是没解 |
| UNBOUNDED | 目标无界 | 查变量上下界是否漏给 |
| TIMELIMIT | 撞墙钟上限 | 可能有 incumbent,取 getBestSol |
| GAPLIMIT | 达到 gap 阈值 | 有解,质量已达标 |
| NODELIMIT | 撞节点上限 | 有解,视业务决定是否续跑 |
| SOLLIMIT | 达到解的个数上限 | 有解,看 getNSols |
一个能直接抄的 dispatch:
from pyscipopt import SCIP_STATUS status = m.getStatus() if status == SCIP_STATUS.OPTIMAL: sol = m.getBestSol() elif status in (SCIP_STATUS.TIMELIMIT, SCIP_STATUS.GAPLIMIT, SCIP_STATUS.NODELIMIT, SCIP_STATUS.SOLLIMIT): sol = m.getBestSol() if sol is None: raise RuntimeError("无可行解,先查约束矛盾而不是调参数") else: m.printStatistics() raise RuntimeError(f"求解异常终止:{status}")这里最大的误解是"TIMELIMIT 等于没解"。实际上时间到了只要找到过 incumbent,getBestSol()就返回那个解,业务上完全可以用。反过来 INFEASIBLE 才是真的证明没有可行解,这时候要做的不是加大时间,是拿writeProblem导出 LP 文件逐条查约束。
4.2 解对象:getBestSol、getSolVal、getSolObjVal
getBestSol()返回一个Solution对象,本身不携带数值,取值要靠getSolVal:
sol = m.getBestSol() # 单变量取值 start_vals = {j: m.getSolVal(sol, start[j]) for j in jobs} # 整个表达式也能直接求值,省得自己拼 total_setup = m.getSolVal(sol, quicksum( x[j, k] * setup_cost[j][k] for j in jobs for k in machines)) # 解的目标值,original=True 表示按原始(未变换)问题算 obj = m.getSolObjVal(sol, original=True) gap = m.getGap()getSolVal的第二个参数可以是单个变量、表达式、或者约束(求的是 activity),这个灵活性文档里写得不显眼但很好用。getObjVal()是不带参数的快捷方式,等价于当前最优解的getSolObjVal,没有可行解时会抛异常。getVal(var)是另一条路,但它要求变量已经经过 SCIP 的变换(transformed),在optimize之前调用会直接报错——这是新手最常撞的一堵墙。
要枚举所有解用getSols(),配合getNSols()做多解分析。如果要在回调里自己造解塞回去,走createSol()+setSolVal()+addSol()这一套,注意addSol之后还要freeSol释放,否则内存会缓慢爬升。
4.3 对偶信息与 LP 松弛:给敏感性分析留个口子
排产模型交付之后业务方一定会问"这台机器产能再加一小时能省多少钱",答案在对偶值里:
# 只对线性约束有效,且必须在 optimize 之后 for c in m.getConss(): dual = m.getDualsolLinear(c) # 对偶值,影子价格 slack = m.getSlack(c, m.getBestSol()) print(c.name, dual, slack)getDualsolLinear(cons)只对线性约束有效,二次约束、逻辑约束、indicator 约束调用会抛异常,得先判断类型。它返回的是当前最优 LP 解的对偶值,MIP 求完之后 SCIP 保留的 LP 是最后一个节点的松弛,不一定对应最优整数解——做严格的敏感性分析要设presolving/maxrounds=0和separating/maxrounds=0再跑一遍,让对偶信息干净一点。
getLPSolstat()返回 LP 状态枚举,getLPSolstat() == SCIP_LPSOLSTAT.OPTIMAL才能信任对偶值。getPrimalbound()和getDualbound()是原始界和对偶界,两者相除就是 gap 的来源,日志里那一行Primal Bound / Dual Bound / Gap就是它们。getNLPs()和getNLPIterations()用来判断瓶颈在 LP 求解还是分支,这两个数除以节点数,单节点 LP 次数超过 5 通常意味着分离太猛了,该去调separating/*。
5. 参数调优与常见排查:五类高频翻车现场
5.1 参数三层写法与真正影响结果的那几个
PySCIPOpt 的参数接口有三层,用哪层取决于你要不要类型检查:
m.setParam("limits/time", 120.0) # 自动推断类型,最省事 m.setRealParam("limits/gap", 0.005) # 显式指定,拼错立刻报错 m.setIntParam("parallel/maxnthreads", 4) m.setBoolParam("misc/allowstrongdualreds", True) m.readParams("tuning.set") # 从 SCIP 参数文件批量读setParam会自动推断,但推断失败时的报错信息很难懂;setRealParam、setIntParam这些显式接口在类型对不上时直接抛异常,调试期优先用它们。参数名拼错这件事在不同版本里行为不一致——有的版本抛 KeyError,有的版本只打一行 warning 继续跑。稳妥做法是先m.getParam("limits/time")探一下,能读到再设。
实际项目里真正值得动的参数不多:
| 参数 | 类型 | 典型值 | 作用 |
|---|---|---|---|
| limits/time | real | 60~3600 | 墙钟上限,最先设 |
| limits/gap | real | 0.001~0.05 | 相对 gap,业务能接受多少就设多少 |
| limits/nodes | longint | 1e5 | 节点上限,防内存爆 |
| presolving/maxrounds | int | 0 关,-1 默认 | 预求解轮数,排查不可行时先关 |
| separating/maxrounds | int | 0 关 | 分离轮数,排查对偶时关 |
| heuristics/*/freq | int | -1 关 | 单个启发式频率 |
| parallel/maxnthreads | int | 4 | 并发线程,要和 threadsafe 配套 |
| display/verblevel | int | 0~5 | 日志级别 |
limits/gap是我见过被滥用最多的一个。业务方说"要最优解",实际交付标准是"和人工排产比好 5% 就行",设成 1e-4 白白多跑半小时。反过来设成 0.1 又可能拿到一个比人工还差的解。常见做法是先跑一次 300 秒不设 gap,看 SCIP 自己能压到多少,再往上浮一档定阈值。
5.2 五类高频翻车现场:现象、原因、解决
第一类:getSolVal报 "solution is not feasible" 或者返回 None。现象是明明getStatus()返回 OPTIMAL,取解却炸掉。原因是getBestSol()返回的 Solution 对象在后续调用了freeSol或者模型被freeProb之后失效了,也可能是在optimize之前就调用了取解方法。解决方式很固定:取解操作全部放在optimize之后、freeProb之前,并且每次取解前先判sol is not None。
第二类:目标值翻倍,和手算对不上。现象是解出来的 obj 恰好是预期的两倍,检查变量值又都对。原因是addVar(obj=...)里带了目标系数,setObjective里又把这部分加了一遍,两处叠加。解决:要么全部走addVar(obj=),要么全部走setObjective,别混着用,我现在的习惯是统一走setObjective,obj参数一律不填。
第三类:时间限制设了 60 秒,实际跑了四十分钟。现象是limits/time明明设了,getSolvingTime()却远超这个数。原因通常有两个:一是时间限制在预求解阶段和根节点 LP 阶段不强制中断,模型特别大时预求解本身就能吃掉几分钟;二是limits/time设在了optimize之前但后面又调了readParams,参数文件里的值把它覆盖了。解决:readParams放在所有setParam之前,optimize之后看getPresolvingTime()和getSolvingTime()分别多少,预求解占比高就设presolving/maxrounds限一限。
第四类:模型业务上明明有解,SCIP 却判 INFEASIBLE。现象是getStatus()返回 INFEASIBLE,但手工排一版能排出来。原因九成是大 M 给小了,把可行域切掉了一块;剩下的一成是整数容差——浮点系数加整型约束,预求解阶段做系数收紧时把一个理论上可行的点推到了容差外。解决:先把大 M 换成addConsIndicator,立竿见影;换不了就把numerics/feastol从默认 1e-6 放到 1e-7 再跑一遍,同时用m.writeProblem("debug.lp")导出,拿 LP 文件喂给别的求解器交叉验证。
第五类:freeProb()之后继续访问模型对象,进程直接段错误。现象是脚本跑完第一个场景、进入第二个场景时进程崩掉,没有 Python traceback。原因是freeProb()释放了底层 C 指针,但 Python 侧的 Model 对象还在,任何后续方法调用都会解引用空指针;另一种变体是用sourceModel复制出来的模型,父模型freeProb之后子模型的部分引用跟着失效。解决:freeProb只在模型彻底不用时调,批量场景一律"一个场景一个 Model 对象",靠 Python 的 GC 回收,别手工释放。真要用freeProb,之后所有变量、约束、解对象全部当已销毁。
6. 一个可复现的验证流程:从手算小例到规模化基准
一套模型交出去之前,我固定跑五步做交叉验证,每一步都能抓到不同层的问题。
第一步,缩到两变量一手算例。把业务模型的分支逻辑抽出来,只留两个二元变量、一条约束,手算最优解是几,跑 PySCIPOpt 对答案。这一步抓的是建模符号错误,成本五分钟,收益巨大。
第二步,导出 LP 文件人工过一遍。m.writeProblem("model.lp"),打开文件看每一条约束的系数和符号,特别是>=和<=有没有写反、常数项有没有漏。我见过最离谱的一次是目标函数里延迟惩罚的权重写成了负数,求解器高高兴兴把开工时刻推到最晚,业务方看傻了。
第三步,关预求解和分离各跑一遍做对照:
from pyscipopt import Model, SCIP_PARAMSETTING for setting in ("default", "no_presolve", "no_sepa"): m = build_model() # 每次重建,不复用对象 if setting == "no_presolve": m.setPresolve(SCIP_PARAMSETTING.OFF) elif setting == "no_sepa": m.setSeparating(SCIP_PARAMSETTING.OFF) m.setRealParam("limits/time", 300.0) m.optimize() print(setting, m.getStatus(), m.getObjVal(), m.getNNodes())三个结果的最优解目标值应该完全一致,不一致说明某个预求解规则或分离器改变了可行域,通常是数值问题,回去查系数尺度——量纲差三四个数量级的时候,SCIP 的数值容差判断会出问题,先把变量和约束统一到相近量级再跑。
第四步,单线程跑一次基准。setIntParam("parallel/maxnthreads", 1),记录getSolvingTime()、getNNodes()、getNLPs()、getNCutsApplied()四个数做基线。并发下的求解时间不可复现,性能调优必须单线程对比。
第五步,用真实规模数据跑一次完整的,看内存峰值和getGap()收敛曲线。有一张对照表我每次都填:
| 指标 | 单线程基线 | 调参后 | 判定 |
|---|---|---|---|
| 求解时间(秒) | 实测 | 实测 | 降幅 < 10% 就别调了 |
| 节点数 | 实测 | 实测 | 涨了说明分支变差 |
| 单节点 LP 次数 | NLPs/NNodes | 同左 | 超过 5 说明分离过猛 |
| 割数 / 应用割数 | 实测 | 实测 | 比值低说明割没起作用 |
| 内存峰值(RSS) | 实测 | 实测 | 超过 8G 要查 dynamic 约束 |
这套流程走下来能挡掉九成的问题。早期我图省事跳过第一步和第二步,结果有一次模型跑了六个小时返回 OPTIMAL,交付之后业务方发现排出来的班次存在时间重叠——一条析取约束的大 M 我设成了horizon,而实际最大加工时长比它大,约束在边界上恒成立,完全失效。从那以后我每次把模型交给别人之前,都强制走一遍这两步,宁可多花十分钟。希望帮到你。
本文还有配套的精品资源,点击获取