线性规划技巧: 列生成(Column Generation)

列生成(Column Generation)是一种把线性规划问题分解为小规模子问题的技巧1 2. 它的原理基于单纯形算法. 从一个基本解(Basic Solution)出发, 主问题的系数矩阵只考虑基本解对应的列, 然后求解子问题来生成主问题系数矩阵的列. 这种迭代求解的方法使得我们可以求解一些具有 指数多个变量 的线性规划.

下料问题 (The cutting stock problem)

假设我们是一家原材料生产工厂(例如纸张, 布料, 钢管). 原材料的样子可参考下图.

线性规划技巧: 列生成(Column Generation)_第1张图片

为了生产成品, 我们需要把原材料切分成更小尺寸的 成品材料(final)(示意图如下).
线性规划技巧: 列生成(Column Generation)_第2张图片

下面我们描述一个具体的例子.

原材料长1000 (单位:厘米). 需要把原材料切分成4种长度分别为450, 360, 310, 140的成品材料, 且满足每种成品材料对应的需求量: 97, 610, 395, 211. 我们需要计算原材料的切分方式和数量, 目标是最小化所需原材料的数量.

数学模型

我们先定义可行的切割方式(Feasible cutting pattern).

  • L L L是原材料的长度
  • m m m是成品材料种类的总数(本例 m = 4 m=4 m=4)
  • 用向量 s = ( s 1 , s 2 , . . . , s m ) T s=(s_1, s_2, ..., s_m)^T s=(s1,s2,...,sm)T代表每种成本材料的长度
  • 用向量 a j = ( a 1 , j , a 2 , j , . . . , a m , j ) a_j=(a_{1,j}, a_{2,j}, ..., a_{m,j}) aj=(a1,j,a2,j,...,am,j)代表每种成品材料的切割数量, 其中 j j j代表切割方式

可行切割. 切割方式 j j j可行当且仅当 s T a j ≤ L s^Ta_j \leq L sTajL.

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的成品材料.

对于每一件原材料, 算法需要给出可行的切割方式. 目标是最小化可行切割方式的总数,即消耗原材料的总数. 按照这种思路我们构建一个数学模型.

指标

  • j j j - 可行的切割方式
  • i i i - 成品材料

参数

  • d i d_i di - 成品材料 i i i的需求量
  • a i , j a_{i,j} ai,j - 成品材料 i i i在切割方式 j j j中的数量

决策变量

  • x j ∈ N + x_j \in \mathbb{N}^+ xjN+ – 选择切割方式 j j j的原材料数量

目标

  • min ⁡ ∑ j x j \min \sum_j x_j minjxj

约束

  • 满足成品材料的需求量: ∑ j a i , j x j ≥ d i \sum_{j} a_{i,j} x_j \geq d_i jai,jxjdi

综上所述, 我们得到如下整数线性规划.
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. jxjjai,jxjdi,ixj0,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=bx0

在单纯形算法(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,xN0.

根据基变量的定义, 我们有 x B = B − 1 ( b − N x N ) x_B = B^{-1}(b - Nx_N) xB=B1(bNxN). 因此目标函数可以写成
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(B1bNxN)+cNTxN=cBTB1b+(cNTcBTB1N)xN.

上述目标函数关于 b b b求导, 我们得到 Shadow Price (或Dual Variable):
λ T = c B T B − 1 \lambda^T = c^T_B B^{-1} λT=cBTB1
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=cNTcBTB1N=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 sTyL. 此外, 注意到主问题的目标函数的系数都是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. 综上所述, 子问题满足两个约束:

  • s T y ≤ L s^T y \leq L sTyL
  • 1 − λ T y < 0 1-\lambda^Ty < 0 1λTy<0

考虑到第二个约束是不等式, 我们可以把它转换成优化目标: max ⁡   λ T y \max~ \lambda^T y max λTy.

注意

  1. 当目标函数 λ T y ≥ 1 \lambda^T y \geq 1 λTy1停止迭代, 我们得到主问题的最优解.
  2. 子问题的参数 λ \lambda λ来自主问题求解过程的副产品( λ T = c N T B − 1 \lambda^T=c_N^TB^{-1} λT=cNTB1).

因此, 我们得到如下子问题:
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=1mλiyii=1msiyiLyi0,i=1,2,...,m

求解过程

注意到主问题和子问题都是整数规划, 而且从计算复杂性上来看都是NP-hard问题(证明省略, 感兴趣的同学请参考装箱问题(bin packing)和背包问题(knapsack)). 当问题规模很大时, 求解过程依然很慢.

实际的求解过程如下所述:

  1. 把主问题松弛成线性规划.
  2. 初始化主问题一个基本解对应的系数矩阵. 在本例中, 令 A A A为单位矩阵 E E E, 即 A = ( e 1 , e 2 , . . . , e m ) A = (e_1, e_2, ..., e_m) A=(e1,e2,...,em), 其中列向量 e i e_i ei的第 i i i个分量为1, 代表把原材料切成1根成品材料 i i i(剩下的扔掉).
  3. 迭代求解主问题和子问题, 直到得到主问题的最优解(如下所示) 注意: 此时的最优解可能不是整数解.
DO
 solve master problem;
 solve sub problem (generate column y);
 add column y to master problem;
WHILE(OPT(sub problem) <= 1)
  1. 把主问题的解整数化(rounding).
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. jxjjai,jxjdi,ixj0,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=1mλiyii=1msiyiLyi0,i=1,2,...,m

Python实现

主问题模型

# 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()

完整代码

参考文献


  1. P.C. Gilmore and R.E. Gomory. A linear programming approach to the
    cutting stock problem part i.
    Operations Research 9, 849–859, 1961. ↩︎

  2. P.C. Gilmore and R.E. Gomory. A linear programming approach to the
    cutting stock problempart ii.
    Operations Research 11, 863–888, 1963. ↩︎

你可能感兴趣的:(优化算法)