一、目标函数是对的,报错却是维度

制造业里的小规模优化问题大多是这个模样:两种产品排几台设备、几种原料配出一批合金、几个仓库往几个销地调货。写业务逻辑时人人都会,把 scipy.optimize.linprog 跑起来却频频卡住,报错信息还很不友好——常见的是矩阵乘法的形状不匹配(matmul: Input operand 1 has a mismatch),或者一句 ValueError: Initial guess is not feasible。

近期技术社区里就有一组这样的问答:用 linprog 时 A_ub 设置不正确导致维度报错。这个问题有个容易被忽略的特征:它几乎从不指向”你的模型建错了”,而是指向”你写的系数表和求解器约定的排布方式不一样”。约束矩阵是二维的,而人脑里的约束是一行一句话,把话语摊成矩阵时行列写反、变量顺序错位、加了松弛变量却没同步扩长度,三种错法都能产生完全不同的报错文本,根子却是同一个。

linprog 约束矩阵的形状约定

二、先把契约写清楚

linprog 求解的是标准形式:

min  c @ x
s.t. A_ub @ x <= b_ub
A_eq @ x == b_eq
bounds[i][0] <= x[i] <= bounds[i][1]

四个数组共用一个隐式约定:n 是决策变量个数,m 是约束个数。于是形状要求是刚性的——A_ub 必须是 (m, n),b_ub 长度必须是 m,c 长度必须是 n,bounds 长度必须是 n。换句话说,n 这个数一旦定了,c、bounds、A_ub 的列数三处必须同时跟着变;任何一处不同步,就是维度报错。

n 从哪来?它是你自己列出的决策变量清单的长度,是整个模型的锚点。所有的错,本质上是”变量清单”没有显式写下来,只存在于作者的记忆里。

三、三步建模:变量清单 → 约束清单 → 形状自检

正确的最小例子:两种产品 A、B,两道工序,A 单件利润 40、B 单件利润 30,工序一可用 48 小时、工序二可用 60 小时,求利润最大的产量。linprog 只做最小化,所以利润取负。

import numpy as np
from scipy.optimize import linprog

VARS = ["x_A", "x_B"] # 变量顺序 = 唯一真源,之后不许凭记忆

def row(coefs): # 按变量名填系数,位置由 VARS 决定
return [float(coefs.get(v, 0.0)) for v in VARS]

c = np.array([-40.0, -30.0])
A_ub = np.array([row({"x_A": 2, "x_B": 3}), # 工序一:2h/件 A,3h/件 B
row({"x_A": 4, "x_B": 2})]) # 工序二:4h/件 A,2h/件 B
b_ub = np.array([48.0, 60.0])
bounds = [(0, None), (0, None)]

assert c.shape[0] == len(VARS)
assert A_ub.shape == (len(b_ub), len(VARS))
assert len(bounds) == len(VARS)

res = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
assert res.success, res.message
print(dict(zip(VARS, res.x)), "最大利润 =", -res.fun)

三步的含义是:第一步把变量清单写成一行代码或一段 docstring,明确 n 和每个位置代表谁;第二步把每条约束写成”用变量名表述的一句话”,由 row 这样的辅助函数摊平成系数行,人不再手工数列的位置;第三步是求解前的形状自检,三行 assert 把契约钉死。这段自检代码不超过五行,但它把报错从求解器内部(矩阵乘法失败)提前到你自己的代码里(哪一项长度不对,直接说清楚)。

四、跑不通先查这六条

  1. c 的长度是否等于 n。这是最常见的漏项,尤其是变量拆细之后(把”产量”拆成”各班次产量”)。
  2. bounds 是否是 n 个二元组。给成 (0, None) 单个元组、或者上界写成字符串都会失败。
  3. A_ub 的每一行是否对应一个约束。把约束写成列,等于交付了一个转置矩阵。
  4. 稀疏格式却按稠密索引填。用 lil_matrix 一类稀疏结构时,索引语义与二维列表不同,混用会出现”形状对了但值串位”。
  5. 不等式方向。linprog 的 A_ub 只接受 <=,遇到 >= 必须整行乘 -1(包括 b 的那一项),否则约束方向反了,解会离谱或问题直接不可行。
  6. 需要整数解时不该用 linprog。linprog 只解连续 LP,涉及”开/不停机、指派/不指派”要换 milp。
报错信息里出现什么 大概率是哪一条错了
matmul: Input operand 1 has a mismatch 3(行列写反)或 1(c 与 A_ub 列数不一致)
Initial guess is not feasible 2(bounds 与约束冲突)或 5(方向没翻,给定的初解真不满足)
形状检查直接拒绝,提示矩阵与向量长度不符 2、1 同时没同步,通常是加了松弛变量
求解成功但结果明显荒谬(负产量、超能力) 5(不等式方向)或系数串位(4)
提示问题无界(unbounded) 2(忘写上界)或目标符号没取负
解出小数却要求整数件 6,应换 milp 而不是继续调 linprog 参数

换 milp 的写法差别不大,但约束要打包成对象,且要显式声明哪些变量取整:

from scipy.optimize import Bounds, LinearConstraint, milp

n = len(VARS)
res = milp(c=c,
constraints=LinearConstraint(A_ub, lb=-np.inf, ub=b_ub),
integrality=np.ones(n, dtype=int),
bounds=Bounds(lb=0, ub=np.inf))

五、洹拓视角:工业排产的真实形状

上面的例子只有两个变量,真实排产不是。一旦把问题按”每个订单在每个机台上的加工量”展开,变量数就是订单数乘以机台数,几十个订单配十几台设备,轻松上百到几百个变量。这个规模对纯 LP 来说不算压力,method=’highs’ 的求解器处理起来是常规工作量,所以”规模”从来不是第一障碍。

第一个障碍是问题性质。排产要落地,几乎必然混进整数决策:某台设备今天开还是不开、这批货指派给哪个班组、要不要为并线付一次换型成本。这些是 0/1 变量,linprog 解不了,得走混合整数规划。而混合整数带来的不是”多写几行代码”,是求解时间从秒级跳到分钟甚至小时级,以及”无解时说不清到底是哪条约束造成的”。所以选型判据不该是”哪个模型更精确”,而是”哪个模型在数据缺失时还能给出可解释的答案”。

第二障碍,也是最容易被低估的,是系数从哪来。A_ub 里的每个数字,在工厂里对应工艺路线上的一道工序和一条工时定额。定额没维护、工艺路线靠老师傅记忆、设备能力按名义值填报时,模型解得再准也是在给错误的系数求最优。我们的做法是:系数来源先跑通,再做人工可解释的启发式排产(按交期倒排、按设备能力过滤),最后用 LP 做校验——看这个启发式解离最优目标差多少、哪几道能力约束最紧。差距小就说明启发式够用,不值得上 MILP;差距大且集中在少数约束,就先补那几个环节的定额数据,而不是先补算法。

验收标准我们固定四条:一,求解脚本必须显式打印 n、m 以及 c、A_ub、b_ub、bounds 四项的形状,跑一次就把契约留在日志里;二,求解前必须有形状断言,报错来自自己的 assert 而不是求解器;三,解必须能落回人话——每个非零变量能说出对应哪个订单哪台机,说不清就是索引串位;四,模型结论与现场启发式方案的差距要有记录,差距长期很小却坚持上 MILP 的项目,通常是把优化当成了政绩。