From bdc3bc2011284ee07ec998e6d62ba07747be49c6 Mon Sep 17 00:00:00 2001 From: Jesse Myrberg Date: Sun, 7 Aug 2022 02:46:53 +0300 Subject: [PATCH] Add MTP for bin packing problem (#30) --- README.md | 16 +++ mknapsack/__init__.py | 2 + mknapsack/_algos.f | 4 + mknapsack/_bin_packing.py | 120 +++++++++++++++++++++++ mknapsack/_bounded.py | 3 +- mknapsack/_bounded_change_making.py | 1 + mknapsack/_change_making.py | 1 + mknapsack/_exceptions.py | 6 ++ mknapsack/_generalized_assignment.py | 1 + mknapsack/_multiple.py | 3 +- mknapsack/_single.py | 3 +- mknapsack/_unbounded.py | 3 +- tests/test__bin_packing.py | 139 +++++++++++++++++++++++++++ 13 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 mknapsack/_bin_packing.py create mode 100644 tests/test__bin_packing.py diff --git a/README.md b/README.md index be69c6e..a8b729f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Solving knapsack problems with Python using algorithms by [Martello and Toth](ht * Change-making problem: MTC2 * Bounded change-making problem: MTCB * Generalized assignment problem: MTG, MTHG +* Bin packing problem: MTP Documentation is available [here](https://mknapsack.readthedocs.io). @@ -156,6 +157,21 @@ capacities = [11, 22] res = solve_generalized_assignment(profits, weights, capacities) ``` +### Bin Packing Problem + +```python +from mknapsack import solve_bin_packing + +# Given six items with the following weights: +weights = [4, 1, 8, 1, 4, 2] + +# ...and bins with the following capacity: +capacity = 10 + +# Assign items into bins while minimizing the number of bins required +res = solve_bin_packing(weights, capacity) +``` + ## References diff --git a/mknapsack/__init__.py b/mknapsack/__init__.py index d7555df..a7ea888 100644 --- a/mknapsack/__init__.py +++ b/mknapsack/__init__.py @@ -2,6 +2,7 @@ __all__ = [ + 'solve_bin_packing', 'solve_bounded_knapsack', 'solve_bounded_change_making', 'solve_change_making', @@ -28,6 +29,7 @@ from mknapsack._exceptions import FortranInputCheckError, NoSolutionError, \ ProblemSizeError # noqa: E402 +from mknapsack._bin_packing import solve_bin_packing # noqa: E402 from mknapsack._bounded import solve_bounded_knapsack # noqa: E402 from mknapsack._bounded_change_making import solve_bounded_change_making # noqa: E402, E501 from mknapsack._change_making import solve_change_making # noqa: E402 diff --git a/mknapsack/_algos.f b/mknapsack/_algos.f index a78349b..e1e0949 100644 --- a/mknapsack/_algos.f +++ b/mknapsack/_algos.f @@ -8536,6 +8536,10 @@ subroutine mtp ( n, w, c, z, xstar, jdim, back, jck, lb, wr, c parameters are unchanged except back , which gives the number of c backtrackings performed. c +cf2py intent(in) n, w, c, jdim, back, jck +cf2py intent(out) z, xstar, lb +cf2py intent(hide) wr, xstarr, dum, res, rel, x, r, wa, wb, kfix, fixit, xred, ls, lsb, xheu +cf2py depend(jdim) w, xstar, wr, xstarr, dum, res, rel, x, r, wa, wb, kfix, fixit, xred, ls, lsb, xheu integer w(jdim) integer xstar(jdim),c,z,back integer wr(jdim),xstarr(jdim),dum(jdim),vstar diff --git a/mknapsack/_bin_packing.py b/mknapsack/_bin_packing.py new file mode 100644 index 0000000..6f4108c --- /dev/null +++ b/mknapsack/_bin_packing.py @@ -0,0 +1,120 @@ +"""Module for solving bin packing problem.""" + + +import logging + +from typing import List, Optional + +import numpy as np + +from mknapsack._algos import mtp +from mknapsack._exceptions import FortranInputCheckError +from mknapsack._utils import preprocess_array, pad_array + + +logger = logging.getLogger(__name__) + + +def solve_bin_packing( + weights: List[int], + capacity: int, + method: str = 'mtp', + method_kwargs: Optional[dict] = None, + verbose: bool = False +) -> np.ndarray: + """Solves the bin packing problem. + + Given a set of items with weights, assign each item exactly to one bin + while minimizing the number of bins required. + + Args: + weights: Weight of each item. + capacity: Capacity of the bins. + method: + Algorithm to use for solving, should be one of + + - 'mtp' - provides a fast heuristical solution or an exact + solution if required + + Defaults to 'mtp'. + method_kwargs: + Keyword arguments to pass to a given `method`. + + - 'mtp' + * **require_exact** (int, optional) - Whether to require an + exact solution or not (0=no, 1=yes). Defaults to 0. + * **max_backtracks** (int, optional) - The maximum number + of backtracks to perform when ``require_exact=0``. + Defaults to 100000. + * **check_inputs** (int, optional) - Whether to check + inputs or not (0=no, 1=yes). Defaults to 1. + + Defaults to None. + verbose: Log details of the solution. Defaults to False. + + Returns: + np.ndarray: Assigned bin for each item. + + Raises: + FortranInputCheckError: Something is wrong with the inputs when + validated in the original Fortran source code side. + ValueError: Something is wrong with the given inputs. + + Example: + .. code-block:: python + + from mknapsack import solve_bin_packing + + res = solve_bin_packing( + weights=[4, 1, 8, 1, 4, 2], + capacity=10 + ) + + References: + * Silvano Martello, Paolo Toth, Knapsack Problems: Algorithms and + Computer Implementations, Wiley, 1990, ISBN: 0-471-92420-2, + LC: QA267.7.M37. + + * `Original Fortran77 source code by Martello and Toth\ + `_ + """ + weights = preprocess_array(weights) + n = len(weights) + + # Sort items by weight in descending order + items_reorder = weights.argsort()[::-1] + items_reorder_reverse = items_reorder.argsort() + weights = weights[items_reorder] + + method = method.lower() + method_kwargs = method_kwargs or {} + if method == 'mtp': + jdim = n + w = pad_array(weights, jdim) + + if method_kwargs.get('require_exact', 0): + back = -1 + else: + back = method_kwargs.get('max_backtracks', 100_000) + + z, x, lb = mtp( + n=n, + w=w, + c=capacity, + jdim=jdim, + back=back, + jck=method_kwargs.get('check_inputs', 1) + ) + + if z < 0: + raise FortranInputCheckError(method=method, z=z) + + if verbose: + logger.info(f'Method: "{method}"') + logger.info(f'Total profit: {z}') + logger.info(f'Solution vector: {x}') + logger.info(f'Lower bound: {lb}') + else: + raise ValueError(f'Given method "{method}" not known') + + return np.array(x)[:n][items_reorder_reverse] diff --git a/mknapsack/_bounded.py b/mknapsack/_bounded.py index 6df78b9..831548e 100644 --- a/mknapsack/_bounded.py +++ b/mknapsack/_bounded.py @@ -46,6 +46,7 @@ def solve_bounded_knapsack( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: Number of items assigned to the knapsack for each item @@ -85,7 +86,7 @@ def solve_bounded_knapsack( 'Profits length must be equal to weights and n_items ' f'(not {len(profits) == len(weights) == len(n_items)}') - # Sort items by profit/ratio ratio in ascending order + # Sort items by profit/weights ratio in ascending order items_reorder = (profits / weights).argsort()[::-1] items_reorder_reverse = np.argsort(items_reorder) profits = profits[items_reorder] diff --git a/mknapsack/_bounded_change_making.py b/mknapsack/_bounded_change_making.py index 2fa8a77..c9db8a1 100644 --- a/mknapsack/_bounded_change_making.py +++ b/mknapsack/_bounded_change_making.py @@ -53,6 +53,7 @@ def solve_bounded_change_making( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: Number of items for each item type. diff --git a/mknapsack/_change_making.py b/mknapsack/_change_making.py index 68dd5f0..b6a9836 100644 --- a/mknapsack/_change_making.py +++ b/mknapsack/_change_making.py @@ -51,6 +51,7 @@ def solve_change_making( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: Number of items for each item type. diff --git a/mknapsack/_exceptions.py b/mknapsack/_exceptions.py index dc73a60..8617fad 100644 --- a/mknapsack/_exceptions.py +++ b/mknapsack/_exceptions.py @@ -99,6 +99,12 @@ class FortranInputCheckError(Exception): -3: 'Profit, weight, or capacity is <= 0', -4: 'One or more of weights is greater than knapsack capacity', -5: 'One or more knapsacks cannot fit any items' + }, + 'mtp': { + -1: 'Number of items is less than 2', + -2: 'Item weight or bin capacity is <= 0', + -3: 'One or more of weights is greater than bin capacity', + -4: 'Weights should be in ascending order' } } diff --git a/mknapsack/_generalized_assignment.py b/mknapsack/_generalized_assignment.py index 103e49d..9f8b2e0 100644 --- a/mknapsack/_generalized_assignment.py +++ b/mknapsack/_generalized_assignment.py @@ -74,6 +74,7 @@ def solve_generalized_assignment( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: Assigned knapsack for each item. diff --git a/mknapsack/_multiple.py b/mknapsack/_multiple.py index cb99930..5f11ee9 100644 --- a/mknapsack/_multiple.py +++ b/mknapsack/_multiple.py @@ -63,6 +63,7 @@ def solve_multiple_knapsack( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: The corresponding knapsack for each item, where 0 means @@ -100,7 +101,7 @@ def solve_multiple_knapsack( raise ValueError('Profits length must be equal to weights ' f'({len(profits) != len(weights)}') - # Sort items by profit/ratio ratio in ascending order + # Sort items by profit/weights ratio in ascending order items_reorder = (profits / weights).argsort()[::-1] items_reorder_reverse = items_reorder.argsort() profits = profits[items_reorder] diff --git a/mknapsack/_single.py b/mknapsack/_single.py index 0a46088..02f8300 100644 --- a/mknapsack/_single.py +++ b/mknapsack/_single.py @@ -62,6 +62,7 @@ def solve_single_knapsack( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: Indicator of knapsack assignment for each item, where 0 @@ -113,7 +114,7 @@ def solve_single_knapsack( raise ValueError('Profits length must be equal to weights ' f'({len(profits) != len(weights)}') - # Sort items by profit/ratio ratio in ascending order + # Sort items by profit/weights ratio in ascending order items_reorder = (profits / weights).argsort()[::-1] items_reorder_reverse = items_reorder.argsort() profits = profits[items_reorder] diff --git a/mknapsack/_unbounded.py b/mknapsack/_unbounded.py index b9dfaa7..90aebd7 100644 --- a/mknapsack/_unbounded.py +++ b/mknapsack/_unbounded.py @@ -57,6 +57,7 @@ def solve_unbounded_knapsack( inputs or not (0=no, 1=yes). Defaults to 1. Defaults to None. + verbose: Log details of the solution. Defaults to False. Returns: np.ndarray: Number of items assigned to the knapsack for each item @@ -93,7 +94,7 @@ def solve_unbounded_knapsack( raise ValueError('Profits length must be equal to weights ' f'({len(profits) != len(weights)}') - # Sort items by profit/ratio ratio in ascending order + # Sort items by profit/weights ratio in ascending order items_reorder = (profits / weights).argsort()[::-1] items_reorder_reverse = np.argsort(items_reorder) profits = profits[items_reorder] diff --git a/tests/test__bin_packing.py b/tests/test__bin_packing.py new file mode 100644 index 0000000..207ad42 --- /dev/null +++ b/tests/test__bin_packing.py @@ -0,0 +1,139 @@ +"""Test cases for bin packing problem.""" + + +import numpy as np +import pytest + +from mknapsack._bin_packing import solve_bin_packing +from mknapsack._exceptions import FortranInputCheckError + +from tests.utils import get_id + + +bin_packing_case_small = { + 'case': 'small', + 'weights': [4, 1, 8, 1, 4, 2], + 'capacity': 10, + 'total_profit': 2, + 'solution': [2, 2, 1, 2, 2, 1] +} + +bin_packing_case_small_reverse = { + 'case': 'small-reverse', + 'weights': [4, 1, 8, 1, 4, 2][::-1], + 'capacity': 10, + 'total_profit': 2, + 'solution': [2, 2, 1, 2, 2, 1][::-1] +} + +bin_packing_case_medium = { + 'case': 'medium', + 'weights': [18, 9, 23, 20, 59, 61, 70, 75, 65, 30] * 10, + 'capacity': 190, + 'total_profit': 23, + 'solution': [ + 23, 23, 21, 22, 20, 18, 9, 4, 11, 4, 22, 3, 22, 8, 15, 18, 7, + 5, 13, 5, 23, 23, 21, 7, 12, 17, 6, 2, 13, 3, 23, 5, 21, 22, + 14, 16, 10, 3, 14, 2, 23, 23, 21, 9, 13, 17, 8, 3, 15, 6, 23, + 20, 21, 22, 19, 16, 8, 1, 14, 10, 23, 4, 22, 10, 11, 16, 7, 1, + 15, 8, 23, 2, 21, 6, 20, 17, 6, 4, 11, 7, 22, 1, 21, 22, 19, + 18, 10, 5, 12, 9, 23, 19, 21, 22, 20, 19, 9, 2, 12, 1] +} + +bin_packing_case_large = { + 'case': 'large', + 'weights': [18, 9, 23, 20, 59, 61, 70, 75, 65, 30] * 1000, + 'capacity': 190, + 'total_profit': None, + 'solution': None +} + +bin_packing_success_cases = [ + {'method': 'mtp', **bin_packing_case_small}, + {'method': 'mtp', **bin_packing_case_small_reverse}, + {'method': 'mtp', **bin_packing_case_medium}, + {'method': 'mtp', **bin_packing_case_large, + 'method_kwargs': {'require_exact': 0, 'max_backtracks': 5000}} +] + +bin_packing_fail_cases_base = [ + { + 'case': 'only_one_item', + 'methods': ['mtp'], + 'weights': [4], + 'capacity': 10, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'weight_lte_0', + 'methods': ['mtp'], + 'weights': [4, 1, 8, 0, 4, 2], + 'capacity': 10, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'capacity_lte_0', + 'methods': ['mtp'], + 'weights': [4, 1, 8, 1, 4, 2], + 'capacity': 0, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'item_weight_gt_capacity', + 'methods': ['mtp'], + 'weights': [4, 1, 11, 1, 4, 2], + 'capacity': 10, + 'fail_type': FortranInputCheckError + } +] + +bin_packing_fail_cases = [ + {**case, 'method': method} + for case in bin_packing_fail_cases_base + for method in case['methods'] +] + + +@pytest.mark.parametrize('params', bin_packing_success_cases, ids=get_id) +def test_solve_bin_packing(params): + # Get function arguments from params + weights = params['weights'] + capacity = params['capacity'] + + total_profit = params['total_profit'] + solution = params['solution'] + tolerance = params.get('tolerance', 0) + + func_kwargs = dict(weights=weights, capacity=capacity) + for opt_param in ['method', 'method_kwargs', 'verbose']: + if opt_param in params: + func_kwargs[opt_param] = params[opt_param] + + # Run algorithm + res = solve_bin_packing(**func_kwargs) + + assert isinstance(res, np.ndarray) + assert len(res) == len(weights) + + # Ensure no overweight in knapsack + weights = np.array(weights) + for i in range(res.max()): + assert weights[res == i + 1].sum() <= capacity + + # Ensure profit within given limits + res_profit = res.max() + if total_profit is not None: + assert res_profit >= (1 - tolerance) * total_profit and \ + res_profit <= (1 + tolerance) * total_profit + + # Ensure global optimum when tolerance = 0 + if solution is not None and tolerance == 0: + assert np.allclose(res, solution) + + +@pytest.mark.parametrize('params', bin_packing_fail_cases, ids=get_id) +def test_solve_bin_packing_fail(params): + del params['case'], params['methods'] + fail_type = params.pop('fail_type') + with pytest.raises(fail_type): + solve_bin_packing(**params)