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

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

下料问题 (The cutting stock problem)

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

原材料. 来自网络

为了生产成品, 我们需要把原材料切分成更小尺寸的 成品材料(final)(示意图如下).

切割示例

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

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

数学模型

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

  • 设是原材料的长度
  • 设是成品材料种类的总数(本例)
  • 用向量代表每种成本材料的长度
  • 用向量代表每种成品材料的切割数量, 其中代表切割方式

可行切割. 切割方式可行当且仅当.

=1000. . 那么代表把一根原材料切成2段360的成品材料和 2段140的成品材料.

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

指标

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

参数

  • - 成品材料的需求量
  • - 成品材料在切割方式中的数量

决策变量

  • -- 选择切割方式的原材料数量

目标

约束

  • 满足成品材料的需求量:

综上所述, 我们得到如下整数线性规划.

求解思路

直接求解

模型的输入需要枚举所有的可行切割方式, 即需要构造如下矩阵:

其中代表可行切割方式的总数, 代表第种可行切割方式对应的列向量, .

如果成品材料的种类会发生改变, 这种直接计算的方式可能变得不可行. 原因是可行切割数量随着成品材料种类数量的增大呈指数增长, 这样一来可能导致矩阵A非常大, 进而导致计算过程中的内存消耗超出限制.

间接求解

把问题分解为主问题(Master problem)和子问题(Sub problem). 主问题只考虑 一部分 可行的切割方式, 例如考虑; 通过求解子问题, 我们生成可行的切割方式, 然后把它加入主问题的输入矩阵, 例如; 再次求解主问题; 反复迭代求解直到满足停止条件. 我们把这种计算方式称为列生成. 这种方式的好处是把大问题拆分成了许多小问题, 然后通过迭代的方式来求解.

列生成

回顾单纯形算法

已知线性规划的标准形式如下:

在单纯形算法(Simplex algorithm)中, 决策变量被分成两类: 基变量和非基变量. 两类变量对应的矩阵我们记作和. 因此, 线性规划也可以写成:

根据基变量的定义, 我们有. 因此目标函数可以写成

上述目标函数关于求导, 我们得到 Shadow Price (或Dual Variable):

对求导我们得到Reduced Cost (非基变量每增加一个单位, 目标函数的改变值):

单纯形算法从一个基本解开始迭代, 当存在 (的第个分量), 则把对应的加入基(basis)中, 同时剔除一个已有的基变量, 因此增加可以降低目标值. 如此迭代下去直到, 目标值无法再降低, 因此得到最优解.

列生成模型

本节我们考虑把原始问题拆分成更小的主问题和子问题, 然后分别迭代求解. 在主问题中, 一开始只考虑基本解对应的矩阵, 然后通过求解子问题来生成主问题的列, 从而把它加入到主问题中进行求解. 根据上面的公式 , 我们只需要生成 能降低目标函数的列, 即对应的分量严格小于0. (因此没有必要枚举所有可能性.)

设代表种成品材料的长度. 令是子问题要生成的可行切分方式, 因此需要满足条件: . 此外, 注意到主问题的目标函数的系数都是1, 即, 因此对应的reducde cost 为. 综上所述, 子问题满足两个约束:

考虑到第二个约束是不等式, 我们可以把它转换成优化目标: .

注意

  1. 当目标函数停止迭代, 我们得到主问题的最优解.
  2. 子问题的参数来自主问题求解过程的副产品().

因此, 我们得到如下子问题:

求解过程

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

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

  1. 把主问题松弛成线性规划.
  2. 初始化主问题一个基本解对应的系数矩阵. 在本例中, 令为单位矩阵, 即, 其中列向量的第个分量为1, 代表把原材料切成1根成品材料(剩下的扔掉).
  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)

子问题(sub problem)

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. ↩

你可能感兴趣的:(线性规划技巧: 列生成(Column Generation))