列生成(Column Generation)是一种把线性规划问题分解为小规模子问题的技巧1 2. 它的原理基于单纯形算法. 从一个基本解(Basic Solution)出发, 主问题的系数矩阵只考虑基本解对应的列, 然后求解子问题来生成主问题系数矩阵的列. 这种迭代求解的方法使得我们可以求解一些具有 指数多个变量 的线性规划.
假设我们是一家原材料生产工厂(例如纸张, 布料, 钢管). 原材料的样子可参考下图.
为了生产成品, 我们需要把原材料切分成更小尺寸的 成品材料(final)(示意图如下).
下面我们描述一个具体的例子.
原材料长1000 (单位:厘米). 需要把原材料切分成4种长度分别为450, 360, 310, 140的成品材料, 且满足每种成品材料对应的需求量: 97, 610, 395, 211. 我们需要计算原材料的切分方式和数量, 目标是最小化所需原材料的数量.
我们先定义可行的切割方式(Feasible cutting pattern).
可行切割. 切割方式 j j j可行当且仅当 s T a j ≤ L s^Ta_j \leq L sTaj≤L.
例 L L L=1000. s = ( 450 , 360 , 310 , 140 ) s=(450, 360, 310, 140) s=(450,360,310,140). 那么 a = ( 0 , 2 , 0 , 2 ) T a=(0, 2, 0, 2)^T a=(0,2,0,2)T代表把一根原材料切成2段360的成品材料和 2段140的成品材料.
对于每一件原材料, 算法需要给出可行的切割方式. 目标是最小化可行切割方式的总数,即消耗原材料的总数. 按照这种思路我们构建一个数学模型.
指标
参数
决策变量
目标
约束
综上所述, 我们得到如下整数线性规划.
min ∑ j x j s.t. ∑ j a i , j x j ≥ d i , ∀ i x j ≥ 0 , integer , ∀ j \begin{aligned} \min ~ & \sum_j x_j \\ \text{ s.t. } & \sum_{j} a_{i,j} x_j \geq d_i, \forall i \\ & x_j \geq 0, \text{integer}, \forall j \end{aligned} min s.t. j∑xjj∑ai,jxj≥di,∀ixj≥0,integer,∀j
模型的输入需要枚举所有的可行切割方式, 即需要构造如下矩阵:
A = [ a 1 , a 2 , . . . , a n ] , A = [a_1, a_2, ..., a_n], A=[a1,a2,...,an],
其中 n n n代表可行切割方式的总数, a j a_j aj代表第 j j j种可行切割方式对应的列向量, j = 1 , 2 , . . . , n j=1, 2, ..., n j=1,2,...,n.
如果成品材料的种类会发生改变, 这种直接计算的方式可能变得不可行. 原因是可行切割数量 n n n随着成品材料种类数量 m m m的增大呈指数增长, 这样一来可能导致矩阵A非常大, 进而导致计算过程中的内存消耗超出限制.
把问题分解为主问题(Master problem)和子问题(Sub problem). 主问题只考虑 一部分 可行的切割方式, 例如考虑 A ′ = [ a 1 , a 2 , . . . , a m ] A'=[a_1, a_2, ..., a_m] A′=[a1,a2,...,am]; 通过求解子问题, 我们生成可行的切割方式 a m + 1 a_{m+1} am+1, 然后把它加入主问题的输入矩阵, 例如 A ′ = [ a 1 , a 2 , . . . , a m + 1 ] A' = [a_1, a_2, ..., a_{m+1}] A′=[a1,a2,...,am+1]; 再次求解主问题; 反复迭代求解直到满足停止条件. 我们把这种计算方式称为列生成. 这种方式的好处是把大问题拆分成了许多小问题, 然后通过迭代的方式来求解.
已知线性规划的标准形式如下:
min c T x s.t. A x = b x ≥ 0 \begin{aligned} \min ~ & c^T x \\ \text{s.t. } & Ax = b\\ & x \geq 0 \end{aligned} min s.t. cTxAx=bx≥0
在单纯形算法(Simplex algorithm)中, 决策变量被分成两类: 基变量 x B x_B xB和非基变量 x N x_N xN. 两类变量对应的矩阵我们记作 B B B和 N N N. 因此, 线性规划也可以写成:
min c B T x B + c N T x N s.t. B x B + N x N = b x B , x N ≥ 0. \begin{aligned} \min ~ & c^T_B x_B + c^T_N x_N \\ \text{s.t. } & B x_B + N x_N = b \\ & x_B, x_N \geq 0. \end{aligned} min s.t. cBTxB+cNTxNBxB+NxN=bxB,xN≥0.
根据基变量的定义, 我们有 x B = B − 1 ( b − N x N ) x_B = B^{-1}(b - Nx_N) xB=B−1(b−NxN). 因此目标函数可以写成
c B T ( B − 1 b − N x N ) + c N T x N = c B T B − 1 b + ( c N T − c B T B − 1 N ) x N . c^T_B(B^{-1}b - Nx_N) + c^T_N x_N = c^T_BB^{-1}b + (c^T_N - c^T_BB^{-1}N)x_N. cBT(B−1b−NxN)+cNTxN=cBTB−1b+(cNT−cBTB−1N)xN.
上述目标函数关于 b b b求导, 我们得到 Shadow Price (或Dual Variable):
λ T = c B T B − 1 \lambda^T = c^T_B B^{-1} λT=cBTB−1
对 x N x_N xN求导我们得到Reduced Cost (非基变量每增加一个单位, 目标函数的改变值):
μ T = c N T − c B T B − 1 N = c N T − λ T N . \mu^T = c^T_N - c^T_B B^{-1}N = c^T_N - \lambda^T N. μT=cNT−cBTB−1N=cNT−λTN.
单纯形算法从一个基本解 x B 0 x_B^0 xB0开始迭代, 当存在 μ j < 0 \mu_j < 0 μj<0 ( μ \mu μ的第 j j j个分量), 则把对应的 x j x_j xj加入基(basis)中, 同时剔除一个已有的基变量, 因此增加 x j x_j xj可以降低目标值. 如此迭代下去直到 μ ≥ 0 \mu \geq 0 μ≥0, 目标值无法再降低, 因此得到最优解.
本节我们考虑把原始问题拆分成更小的主问题和子问题, 然后分别迭代求解. 在主问题中, 一开始只考虑基本解对应的矩阵 B B B, 然后通过求解子问题来生成主问题的列, 从而把它加入到主问题中进行求解. 根据上面的公式 μ T = c N T − λ T N \mu^T = c^T_N - \lambda^T N μT=cNT−λTN, 我们只需要生成 能降低目标函数的列, 即 μ \mu μ对应的分量严格小于0. (因此没有必要枚举所有可能性.)
设 s T = ( s 1 , s 2 , . . . , s m ) s^T=(s_1, s_2, ..., s_m) sT=(s1,s2,...,sm)代表 m m m种成品材料的长度. 令 y y y是子问题要生成的可行切分方式, 因此需要满足条件: s T y ≤ L s^T y \leq L sTy≤L. 此外, 注意到主问题的目标函数的系数都是1, 即 c N T = ( 1 , 1 , . . . , 1 ) c_N^T = (1, 1, ... ,1) cNT=(1,1,...,1), 因此 y y y对应的reducde cost 为 μ y = 1 − λ T y < 0 \mu_y = 1 - \lambda^T y < 0 μy=1−λTy<0. 综上所述, 子问题满足两个约束:
考虑到第二个约束是不等式, 我们可以把它转换成优化目标: max λ T y \max~ \lambda^T y max λTy.
注意
因此, 我们得到如下子问题:
max ∑ i = 1 m λ i y i s.t. ∑ i = 1 m s i y i ≤ L y i ≥ 0 , i = 1 , 2 , . . . , m \begin{aligned} \max ~ & \sum_{i=1}^m {\lambda}_i y_i \\ \text{s.t. } & \sum_{i=1}^m s_i y_i \leq L\\ & y_i \geq 0, i = 1, 2, ..., m \end{aligned} max s.t. i=1∑mλiyii=1∑msiyi≤Lyi≥0,i=1,2,...,m
注意到主问题和子问题都是整数规划, 而且从计算复杂性上来看都是NP-hard问题(证明省略, 感兴趣的同学请参考装箱问题(bin packing)和背包问题(knapsack)). 当问题规模很大时, 求解过程依然很慢.
实际的求解过程如下所述:
DO
solve master problem;
solve sub problem (generate column y);
add column y to master problem;
WHILE(OPT(sub problem) <= 1)
1. 把分数解向下取整(round down), 并计算没有被满足的需求.
2. 把未满足的成品材料按长度从大到小排序.
3. 依次满足上述成品材料(用直观的方式).
主问题 (master problem)
min ∑ j x j s.t. ∑ j a i , j x j ≥ d i , ∀ i x j ≥ 0 , ∀ j \begin{aligned} \min ~ & \sum_j x_j \\ \text{s.t. } & \sum_j a_{i,j} x_j \geq d_i, \forall i \\ & x_j \geq 0, \forall j \end{aligned} min s.t. j∑xjj∑ai,jxj≥di,∀ixj≥0,∀j
子问题(sub problem)
max ∑ i = 1 m λ i y i s.t. ∑ i = 1 m s i y i ≤ L y i ≥ 0 , i = 1 , 2 , . . . , m \begin{aligned} \max ~ & \sum_{i=1}^m {\lambda}_i y_i \\ \text{s.t. } & \sum_{i=1}^m s_i y_i \leq L\\ & y_i \geq 0, i = 1, 2, ..., m \end{aligned} max s.t. i=1∑mλiyii=1∑msiyi≤Lyi≥0,i=1,2,...,m
主问题模型
# master_model.py
from ortools.linear_solver import pywraplp
import numpy as np
class MasterModel(object):
def __init__(self, A, d):
"""
:param A: 可行切割矩阵(每列代表一种切割方式)(m*n维矩阵),
其中m代表成品材料类型总数, n是我们考虑的可行切割方式的数量
:param d: 成品材料的需求量(m维向量), m代表成品材料类型的总数
"""
self._solver = pywraplp.Solver('MasterModel',
pywraplp.Solver.CLP_LINEAR_PROGRAMMING)
self._A = A
self._d = d
self._x = None # 决策变量
self._m, self._n = np.array(self._A).shape
self._constraints = None # 约束的集合(需要根据约束得到对偶变量, 别名shadow price)
self._solution_x = None # 计算结果
self._sp = None # shadow price(m维向量)
def _init_decision_variables(self):
self._x = [self._solver.NumVar(0, self._solver.Infinity(), "x[%d]" % j)
for j in range(self._n)]
def _init_constraints(self):
self._constraints = [None] * self._m
for i in range(self._m):
ct = self._solver.Constraint(self._d[i], self._d[i])
for j in range(self._n):
ct.SetCoefficient(self._x[j], self._A[i][j])
self._constraints[i] = ct
def _init_objective(self):
obj = self._solver.Objective()
for j in range(self._n):
obj.SetCoefficient(self._x[j], 1)
obj.SetMinimization()
def solve(self):
self._init_decision_variables()
self._init_constraints()
self._init_objective()
self._solver.Solve()
self._solution_x = [self._x[j].solution_value() for j in range(self._n)]
# shadow price
self._sp = [self._constraints[i].dual_value() for i in range(self._m)]
def print_info(self):
print("[Master problem info]")
print(" - Objective value =", self.get_objective_value())
print(" - Shadow price: lambda = ", self._sp)
def get_sp(self):
""" 得到shadow price
"""
return self._sp
def get_solution(self):
return self._solution_x
def get_objective_value(self):
return sum(self._solution_x)
子问题模型
# sub_model.py
from ortools.linear_solver import pywraplp
class SubModel(object):
def __init__(self, sp, s, L):
"""
:param sp: 主问题的shadow price(m维度向量), m代表成品材料的类型总数
:param s: 成品材料的长度(m维向量)
:param L: 原材料的总长度
"""
self._solver = pywraplp.Solver('MasterModel',
pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING)
self._sp = sp
self._s = s
self._L = L
self._m = len(s)
self._y = None # 决策变量
self._solution_y = None # 计算结果
def _init_decision_variables(self):
self._y = [self._solver.IntVar(0, self._solver.Infinity(), "y[%d]" % i)
for i in range(self._m)]
def _init_constraints(self):
ct = self._solver.Constraint(0, self._L)
for i in range(self._m):
ct.SetCoefficient(self._y[i], self._s[i])
def _init_objective(self):
obj = self._solver.Objective()
for i in range(self._m):
obj.SetCoefficient(self._y[i], self._sp[i])
obj.SetMaximization()
def solve(self):
self._init_decision_variables()
self._init_constraints()
self._init_objective()
self._solver.Solve()
self._solution_y = [self._y[i].solution_value() for i in range(self._m)]
def print_info(self):
print("[Sub problem info]")
print(" - Reduced cost of master problem =", 1 - self.get_objective_value())
print(" - New column generated:", self._solution_y)
def get_solution(self):
return self._solution_y
def get_objective_value(self):
return sum(map(lambda x: x[0]*x[1], zip(self._sp, self._solution_y)))
列生成迭代求解的流程
# cg_proc.py
import numpy as np
from master_model import MasterModel
from sub_model import SubModel
class CGProc(object):
"""
Column Generation Process
"""
def __init__(self, s, d, L, max_iter=10000):
"""
:param s: 每种成品材料的长度(m维向量)
:param d: 每种成品材料的需求量(m维向量)
:param L: 原材料的长度
:param max_iter: 最大循环次数
"""
self._s = s
for val in s:
if val > L:
raise ValueError("final size cannot be larger than raw size!")
self._d = d
self._L = L
self._reduced_cost = -1
self._iter_times = 0
self._max_iter = max_iter
self._status = -1 # -1:执行错误; 0:最优解; 1: 达到最大循环次数
self._A = None # Master problem的输入(可行切割矩阵)
self._obj_val = None # 目标函数值
self._x = None # Master problem对应的solution value
self._solution_x = None # 切割方式j需要的原材料数量
self._solution_matrix = None # 切割方式j
def _stop_criteria_is_satisfied(self):
""" 根据reduced cost判断是否应该停止迭代.
"""
if self._reduced_cost > -1e-6:
self._status = 0
return True
if self._iter_times >= self._max_iter:
if self._status == -1:
self._status = 1
return True
return False
def _init_basic_matrix(self):
# 生成单位矩阵
self._A = np.identity(len(self._s))
def add_column(self, y):
a = np.array(self._A)
c = np.array(y).transpose()
self._A = np.c_[a, c]
def solve(self):
print("==== iter %d ====" % self._iter_times)
self._init_basic_matrix()
mp = MasterModel(self._A, self._d)
mp.solve()
mp.print_info()
self._iter_times += 1
while not self._stop_criteria_is_satisfied():
# 1. 解Sub problem
print("==== iter %d ====" % self._iter_times)
sm = SubModel(mp.get_sp(), self._s, self._L)
sm.solve()
# 2. 把生成的列加入到Master problem
self.add_column(sm.get_solution())
# 3. 解Master problem
mp = MasterModel(self._A, self._d)
mp.solve()
self._x = mp.get_solution()
self._obj_val = mp.get_objective_value()
mp.print_info()
# 4. 更新 reduced cost
self._reduced_cost = 1 - sm.get_objective_value()
sm.print_info()
self._iter_times += 1
self._get_solution()
status_str = {-1: "error", 0: "optimal", 1: "attain max iteration"}
print(">>> Terminated. Status:", status_str[self._status])
def _get_solution_indices(self):
# 计算有效的indices
return [i for i in range(len(self._x)) if self._x[i] > 0]
def _get_solution(self):
""" 剔除冗余的结果. 这一步不是必须, 仅用来显示结果.
"""
indices = self._get_solution_indices()
self._solution_x = [self._x[i] for i in indices]
self._solution_matrix = np.array([self._A[:, i] for i in indices]).transpose()
def print_info(self):
print("==== Column Generation Model Summary ====")
print(" - Objective: Total number of raws needed =", self._obj_val)
print(" - Fractional solution: The number of raws needed for each cutting pattern\n", self._solution_x)
print(" - Solution matrix: Cutting patterns\n", self._solution_matrix)
def get_solution_x(self):
return self._solution_x
def get_solution_matrix(self):
return self._solution_matrix
把分数解(Fractional solution)取整的算法如下.
# rounding_proc.py
import numpy as np
from copy import deepcopy
class RoundingProc(object):
def __init__(self, A, x, s, d, L):
"""
:param A: 可行切割矩阵(每列代表一种切割方式)(m*n维矩阵),
其中m代表成品材料类型总数, n是我们考虑的可行切割方式的数量
:param x: 切割方式需要的原材料数量(fractional)(n维向量)
:param s: 成品材料的尺寸(m维向量)
:param d: 成品材料的尺寸对应的需求量(m维向量)
:param L: 原材料的长度
"""
self._A = np.array(A)
self._x = x
self._s = s
self._d = d
self._L = L
self._d0 = None # 被满足的需求量
self._d1 = None # 未被满足的需求量
self._greedy_x = None # 贪心算法的结果: 对应满足d1的部分
self._greedy_matrix = None # 贪心算法的结果: 对应满足d1的部分
self._solution_x = None # 最终结果: 切割方式对应的数量
self._solution_matrix = None # 最终结果: 切割方式对应的矩阵
def _round_down(self):
""" 把分数解self._x向下取整, 然后计算未被满足的需求量.
"""
self._x = list(map(int, self._x))
# 计算被满足的需求量
def cal_d0(i):
return sum(self._A[i] * np.array(self._x))
self._d0 = [cal_d0(i) for i in range(len(self._d))]
# 计算未被满足的需求量
self._d1 = (np.array(self._d) - np.array(self._d0)).tolist()
def _satisfy(self):
""" 用贪心的方式满足需求
:return: 切割方式矩阵
"""
# 把index按成品材料的长度从大到小排序
sorted_indices = list(np.argsort(-np.array(self._s)))
rows = []
x = []
d1 = deepcopy(self._d1)
while sum(d1) > 0:
c = self._greedy_cut(d1, sorted_indices)
if not rows:
rows.append(c)
x.append(1)
else:
if rows[-1] != c:
rows.append(c)
x.append(1)
else:
x[-1] += 1
# 更新需求
d1 = (np.array(d1) - np.array(c)).tolist()
return x, np.array(rows).transpose()
def _greedy_cut(self, d1, sorted_indices):
""" 用贪心的方式切割1根原材料.
:param d1: 未被满足的需求
:param sorted_indices: 成品材料的长度从大到小排序对应的indices
:return: 切割方式,m维向量(m=成品材料的个数)
"""
c = [0] * len(self._d)
raw_len = self._L
for i in sorted_indices:
c[i] = min(raw_len // self._s[i], d1[i])
raw_len -= c[i] * self._s[i]
return c
def solve(self):
self._round_down()
self._greedy_x, self._greedy_matrix = self._satisfy()
self._solution_x = self._x + self._greedy_x
self._solution_matrix = np.c_[self._A, self._greedy_matrix]
def print_info(self):
print("==== Rounding Process Summary ====")
print("[Round down result]")
print(" - Integer solution\n", self._x)
print(" - Unsatisfied demands\n", self._d1)
print("[Greedy result]")
print(" - solution (w.r.t. unsatisfied demand)\n", self._greedy_x)
print(" - Solution matrix(w.r.t. unsatisfied demand)\n", self._greedy_matrix)
print("[Final result]")
print(" - Objective value: The total number of raws needed =", sum(self._solution_x))
print(" - Solution: The number of raws needed for each cutting pattern \n", self._solution_x)
print(" - Solution matrix: Cutting patterns\n", self._solution_matrix)
d0 = [sum(self._solution_matrix[i] * np.array(self._solution_x)) for i in range(len(self._d))]
print(" - Satisfied demands: \n", d0)
主程序
# main.py
from cg_proc import CGProc
from data import s, d, L # cutting-stock instance
from rouding_proc import RoundingProc
if __name__ == '__main__':
# 1. 用列生成模型求解原问题的LP松弛问题
c = CGProc(s, d, L)
c.run()
c.print_info()
# 2. 得到松弛问题的解
A = c.get_solution_matrix()
x = c.get_solution_x()
# 3. 对分数解向下取整,然后用直观的方式满足需求,得到最终的解
r = RoundingProc(A, x, s, d, L)
r.solve()
r.print_info()
完整代码
P.C. Gilmore and R.E. Gomory. A linear programming approach to the
cutting stock problem part i. Operations Research 9, 849–859, 1961. ↩︎
P.C. Gilmore and R.E. Gomory. A linear programming approach to the
cutting stock problempart ii. Operations Research 11, 863–888, 1963. ↩︎