From d6fcb6a1016bc4539feb8dfcefc5f3b24bfa99ea Mon Sep 17 00:00:00 2001 From: Jesse Myrberg Date: Sun, 24 Jul 2022 18:23:35 +0300 Subject: [PATCH] Add mtb2, change output dtype and remove pandas (#24) --- README.md | 24 ++++- mknapsack/__init__.py | 7 +- mknapsack/_algos.f | 5 ++ mknapsack/_bounded.py | 135 ++++++++++++++++++++++++++++ mknapsack/_exceptions.py | 9 ++ mknapsack/_multiple.py | 75 ++++------------ mknapsack/_single.py | 80 +++-------------- pyproject.toml | 3 +- requirements.txt | 3 +- tests/test__bounded.py | 186 +++++++++++++++++++++++++++++++++++++++ tests/test__multiple.py | 78 ++++++++-------- tests/test__single.py | 130 +++++++++++---------------- tests/utils.py | 8 ++ 13 files changed, 496 insertions(+), 247 deletions(-) create mode 100644 mknapsack/_bounded.py create mode 100644 tests/test__bounded.py create mode 100644 tests/utils.py diff --git a/README.md b/README.md index 07f960f..2e84557 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Solving knapsack problems with Python using algorithms by [Martello and Toth](https://dl.acm.org/doi/book/10.5555/98124): * Single 0-1 knapsack problem: MT1, MT2, MT1R (real numbers) +* Bounded knapsack problem: MTB2 * Multiple 0-1 knapsack problem: MTM, MTHM Documentation is available [here](https://mknapsack.readthedocs.io). @@ -41,12 +42,31 @@ weights = [18, 9, 23, 20, 59, 61, 70, 75, 76, 30] # ...and a knapsack with the following capacity: capacity = 190 -# Assign items into the knapsack while maximizing profit +# Assign items into the knapsack while maximizing profits res = solve_single_knapsack(profits, weights, capacity) ``` If your inputs are real numbers, you may set parameter `method='mt1r'`. +### Bounded Knapsack Problem + +```python +from mknapsack import solve_bounded_knapsack + +# Given ten item types with the following profits and weights: +profits = [78, 35, 89, 36, 94, 75, 74, 79, 80, 16] +weights = [18, 9, 23, 20, 59, 61, 70, 75, 76, 30] + +# ..and the number of items for each item type: +n_items = [1, 2, 3, 2, 2, 1, 2, 2, 1, 4] + +# ...and a knapsack with the following capacity: +capacity = 190 + +# Assign items into the knapsack while maximizing profits +res = solve_bounded_knapsack(profits, weights, capacity, n_items) +``` + ### Multiple 0-1 Knapsack Problem ```python @@ -59,7 +79,7 @@ weights = [18, 9, 23, 20, 59, 61, 70, 75, 76, 30] # ...and two knapsacks with the following capacities: capacities = [90, 100] -# Assign items into knapsacks while maximizing profit +# Assign items into knapsacks while maximizing profits res = solve_multiple_knapsack(profits, weights, capacities) ``` diff --git a/mknapsack/__init__.py b/mknapsack/__init__.py index bdb257e..989425c 100644 --- a/mknapsack/__init__.py +++ b/mknapsack/__init__.py @@ -1,6 +1,10 @@ """Solving knapsack problems with Python.""" -__all__ = ['solve_single_knapsack', 'solve_multiple_knapsack'] +__all__ = [ + 'solve_bounded_knapsack', + 'solve_single_knapsack', + 'solve_multiple_knapsack' +] import os import sys @@ -13,5 +17,6 @@ os.add_dll_directory(extra_dll_dir) +from mknapsack._bounded import solve_bounded_knapsack # noqa: E402 from mknapsack._single import solve_single_knapsack # noqa: E402 from mknapsack._multiple import solve_multiple_knapsack # noqa: E402 diff --git a/mknapsack/_algos.f b/mknapsack/_algos.f index fd20379..a141b92 100644 --- a/mknapsack/_algos.f +++ b/mknapsack/_algos.f @@ -6499,6 +6499,11 @@ subroutine mtb2(n,p,w,b,c,z,x,jdim1,jdim2,jfo,jfs,jck,jub, c all the parameters but rd8 are integer. on return of mtb2 all the c input parameters are unchanged. c +cf2py intent(in) n, p, w, b, c, jdim1, jdim2, jfo, jfs, jck +cf2py intent(hide) id1, id2, id3, id4, id5, id6, id7, rd8 +cf2py intent(out) z, x, jub +cf2py depend(jdim1) p, w, b, x +cf2py depend(jdim2) id1, id2, id3, id4, id5, id6, id7, rd8 integer p(jdim1),w(jdim1),b(jdim1),x(jdim1),c,z integer id1(jdim2),id2(jdim2),id3(jdim2),id4(jdim2),id5(jdim2), & id6(jdim2),id7(jdim2) diff --git a/mknapsack/_bounded.py b/mknapsack/_bounded.py new file mode 100644 index 0000000..752c561 --- /dev/null +++ b/mknapsack/_bounded.py @@ -0,0 +1,135 @@ +"""Module for solving bounded knapsack problems. + +TODO: + mtu1: Unbounded single knapsack problem + mtu2: Unbounded single knapsack problem +""" + + +import logging + +from typing import List, Optional + +import numpy as np + +from mknapsack._algos import mtb2 +from mknapsack._exceptions import FortranInputCheckError +from mknapsack._utils import preprocess_array, pad_array + + +logger = logging.getLogger(__name__) + + +def solve_bounded_knapsack( + profits: List[float], + weights: List[float], + n_items: List[float], + capacity: float, + method: str = 'mtb2', + method_kwargs: Optional[dict] = None, + verbose: bool = False +) -> np.ndarray: + """Solves the bounded knapsack problem. + + Given a certain number of item types with profits and weights, and a + knapsack with given capacity, how many of each item type should be picked + to maximize profits? + + Args: + profits: Profit of each item type. + weights: Weight of each item type. + n_items: Number of items available for each item type. + capacity: Capacity of knapsack. + method: Algorithm to use for solving, currently only 'mtb2' is + supported. Defaults to 'mtb2'. + method_kwargs: + Keyword arguments to pass to 'mtb2' algorithm: + + * **require_exact** (int, optional) - Whether to require an + exact solution or not (0=no, 1=yes). Defaults to 0. + * **check_inputs** (int, optional) - Whether to check + inputs or not (0=no, 1=yes). Defaults to 1. + + Defaults to None. + + Returns: + np.ndarray: Number of items assigned to the knapsack for each item + type. + + 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_bounded_knapsack + + res = solve_bounded_knapsack( + profits=[78, 35, 89, 36, 94, 75, 74, 100, 80, 16], + weights=[18, 9, 23, 20, 59, 61, 70, 75, 76, 30], + n_items=[1, 2, 3, 2, 2, 1, 2, 2, 1, 4], + capacity=190 + ) + + 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\ + `_ + """ + profits = preprocess_array(profits) + weights = preprocess_array(weights) + n_items = preprocess_array(n_items) + + if len(profits) != len(weights) or len(profits) != len(n_items): + raise ValueError( + '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 + items_reorder = (profits / weights).argsort()[::-1] + items_reorder_reverse = np.argsort(items_reorder) + profits = profits[items_reorder] + weights = weights[items_reorder] + n_items = n_items[items_reorder] + + n = len(profits) + + method = method.lower() + method_kwargs = method_kwargs or {} + if method == 'mtb2': + jdim1 = n + 1 + jdim2 = n + int(np.ceil(np.log2(n_items).sum())) + 3 + p = pad_array(profits, jdim1) + w = pad_array(weights, jdim1) + b = pad_array(n_items, jdim1) + z, x, jub = mtb2( + n=n, + p=p, + w=w, + b=b, + c=capacity, + jdim1=jdim1, + jdim2=jdim2, + jfo=method_kwargs.pop('require_exact', False), + jfs=1, + jck=method_kwargs.pop('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 (non-original order): {x}') + logger.info(f'Solution upper bound: {jub}') + else: + raise ValueError(f'Given method "{method}" not known') + + # Inverse items and knapsacks to original order + return np.array(x)[:n][items_reorder_reverse] diff --git a/mknapsack/_exceptions.py b/mknapsack/_exceptions.py index a115a29..e15053e 100644 --- a/mknapsack/_exceptions.py +++ b/mknapsack/_exceptions.py @@ -41,6 +41,15 @@ class FortranInputCheckError(Exception): -3.0: 'One or more of weights is greater than knapsack capacity', -4.0: 'Total weight is smaller than knapsack capacity', -5.0: 'Items should be ordered in descending profit/weight order' + }, + 'mtb2': { + -1: 'Number of items is less than 2', + -2: 'Profit, weight, capacity or n_items is <= 0', + -3: 'Total weight (weight * total_n_items) of one or more item ' + 'types is greater than the knapsack capacity', + -4: 'Total weight of all items is smaller than knapsack capacity', + -5: 'Problem with preprocessing before Fortran code', + -6: 'Items should be ordered in descending profit/weight order' } } diff --git a/mknapsack/_multiple.py b/mknapsack/_multiple.py index 42e07bb..cb99930 100644 --- a/mknapsack/_multiple.py +++ b/mknapsack/_multiple.py @@ -6,7 +6,6 @@ from typing import List, Optional import numpy as np -import pandas as pd from mknapsack._algos import mtm, mthm from mknapsack._exceptions import FortranInputCheckError @@ -16,27 +15,6 @@ logger = logging.getLogger(__name__) -def process_results(profits, weights, capacities, x): - """Preprocess multiple 0-1 knapsack results.""" - given_knapsacks = pd.DataFrame({ - 'knapsack_id': np.arange(len(capacities)) + 1, - 'knapsack_capacity': capacities - }) - no_knapsack = pd.DataFrame([{'knapsack_id': 0, 'knapsack_capacity': 0}]) - knapsacks = pd.concat([no_knapsack, given_knapsacks], axis=0) - items = ( - pd.DataFrame({ - 'item_id': np.arange(len(profits)) + 1, - 'profit': profits, - 'weight': weights, - 'knapsack_id': x[:len(profits)] - }) - .merge(knapsacks, on='knapsack_id', how='left') - .assign(assigned=lambda x: x['knapsack_id'] > 0) - ) - return items - - def solve_multiple_knapsack( profits: List[int], weights: List[int], @@ -44,7 +22,7 @@ def solve_multiple_knapsack( method: str = 'mthm', method_kwargs: Optional[dict] = None, verbose: bool = False -) -> pd.DataFrame: +) -> np.ndarray: """Solves the multiple 0-1 knapsack problem. Given a set of items with profits and weights and knapsacks with given @@ -52,7 +30,7 @@ def solve_multiple_knapsack( profits? Args: - profits: Profits of each item. + profits: Profit of each item. weights: Weight of each item. capacities: Capacity of each knapsack. method: @@ -87,8 +65,8 @@ def solve_multiple_knapsack( Defaults to None. Returns: - pd.DataFrame: The corresponding knapsack for each item, where - ``knapsack_id=0`` means that the item is not assigned to a knapsack. + np.ndarray: The corresponding knapsack for each item, where 0 means + that the item was not assigned to a knapsack. Raises: FortranInputCheckError: Something is wrong with the inputs when @@ -123,15 +101,18 @@ def solve_multiple_knapsack( f'({len(profits) != len(weights)}') # Sort items by profit/ratio ratio in ascending order - items_order_idx = (profits / weights).argsort()[::-1] - items_reverse_idx = np.argsort(items_order_idx) - profits = profits[items_order_idx] - weights = weights[items_order_idx] + items_reorder = (profits / weights).argsort()[::-1] + items_reorder_reverse = items_reorder.argsort() + profits = profits[items_reorder] + weights = weights[items_reorder] # Sort knapsacks by their capacity in ascending order - knapsacks_order_idx = capacities.argsort() - knapsacks_reverse_idx = knapsacks_order_idx.argsort() - capacities = capacities[knapsacks_order_idx] + knapsacks_reorder = capacities.argsort() + capacities = capacities[knapsacks_reorder] + knapsack_reorder_reverse_map = { + idx + 1: i + 1 for i, idx in enumerate(knapsacks_reorder.argsort()) + } + knapsack_reorder_reverse_map[0] = 0 n = len(profits) m = len(capacities) @@ -170,10 +151,8 @@ def solve_multiple_knapsack( if verbose: logger.info(f'Method: "{method}"') logger.info(f'Total profit: {z}') - logger.info('Solution vector: ' - f'{x[:n][items_reverse_idx].tolist()}') + logger.info(f'Solution vector (non-original order): {x}') logger.info(f'Number of backtracks: {back}') - elif method == 'mthm': p = pad_array(profits, n + 1) w = pad_array(weights, n + 1) @@ -196,30 +175,12 @@ def solve_multiple_knapsack( if verbose: logger.info(f'Method: "{method}"') logger.info(f'Total profit: {z}') - logger.info('Solution vector: ' - f'{x[:n][items_reverse_idx].tolist()}') + logger.info(f'Solution vector (non-original order): {x}') else: raise ValueError(f'Given method "{method}" not known') # Inverse items and knapsacks to original order - profits = profits[items_reverse_idx] - weights = weights[items_reverse_idx] - x = np.array(x)[items_reverse_idx] - capacities = capacities[knapsacks_reverse_idx] - - res = process_results(profits, weights, capacities, x) - - if verbose: - knapsack_results = ( - res - .groupby('knapsack_id') - .agg( - capacity_used=('weight', 'sum'), - capacity_available=('knapsack_capacity', 'first'), - profit=('profit', 'sum'), - items=('item_id', 'unique') - ) - ) - logger.info(f'Results by knapsack_id:\n{knapsack_results.to_string()}') + res = np.array([knapsack_reorder_reverse_map[i] + for i in x[:n][items_reorder_reverse]]) return res diff --git a/mknapsack/_single.py b/mknapsack/_single.py index 969b6a4..0a46088 100644 --- a/mknapsack/_single.py +++ b/mknapsack/_single.py @@ -1,12 +1,4 @@ -"""Module for solving multiple 0-1 knapsack problems. - -TODO: - mt1r: Single 0-1 knapsack problem with real parameters - - mtb2: Bounded single 0-1 knapsack problem - mtu1: Unbounded single knapsack problem - mtu2: Unbounded single knapsack problem -""" +"""Module for solving single 0-1 knapsack problems.""" import logging @@ -14,7 +6,6 @@ from typing import List, Optional import numpy as np -import pandas as pd from mknapsack._algos import mt1, mt2, mt1r from mknapsack._exceptions import FortranInputCheckError @@ -24,27 +15,6 @@ logger = logging.getLogger(__name__) -def process_results(profits, weights, capacity, x): - """Preprocess single 0-1 knapsack results.""" - given_knapsacks = pd.DataFrame({ - 'knapsack_id': [1], - 'knapsack_capacity': [capacity] - }) - no_knapsack = pd.DataFrame([{'knapsack_id': 0, 'knapsack_capacity': 0}]) - knapsacks = pd.concat([no_knapsack, given_knapsacks], axis=0) - items = ( - pd.DataFrame({ - 'item_id': np.arange(len(profits)) + 1, - 'profit': profits, - 'weight': weights, - 'knapsack_id': x[:len(profits)] - }) - .merge(knapsacks, on='knapsack_id', how='left') - .assign(assigned=lambda x: x['knapsack_id'] > 0) - ) - return items - - def solve_single_knapsack( profits: List[float], weights: List[float], @@ -52,14 +22,14 @@ def solve_single_knapsack( method: str = 'mt2', method_kwargs: Optional[dict] = None, verbose: bool = False -) -> pd.DataFrame: +) -> np.ndarray: """Solves the single 0-1 knapsack problem. Given a set of items with profits and weights and a knapsack with given capacity, which items should we pick in order to maximize profit? Args: - profits: Profits of each item. + profits: Profit of each item. weights: Weight of each item. capacity: Capacity of knapsack. method: @@ -94,8 +64,8 @@ def solve_single_knapsack( Defaults to None. Returns: - pd.DataFrame: The corresponding knapsack for each item, where - ``knapsack_id=0`` means that the item is not assigned to a knapsack. + np.ndarray: Indicator of knapsack assignment for each item, where 0 + means that the item was not assigned to a knapsack. Raises: FortranInputCheckError: Something is wrong with the inputs when @@ -144,10 +114,10 @@ def solve_single_knapsack( f'({len(profits) != len(weights)}') # Sort items by profit/ratio ratio in ascending order - items_order_idx = (profits / weights).argsort()[::-1] - items_reverse_idx = np.argsort(items_order_idx) - profits = profits[items_order_idx] - weights = weights[items_order_idx] + items_reorder = (profits / weights).argsort()[::-1] + items_reorder_reverse = items_reorder.argsort() + profits = profits[items_reorder] + weights = weights[items_reorder] n = len(profits) @@ -172,8 +142,7 @@ def solve_single_knapsack( if verbose: logger.info(f'Method: "{method}"') logger.info(f'Total profit: {z}') - logger.info('Solution vector: ' - f'{x[:n][items_reverse_idx].tolist()}') + logger.info(f'Solution vector (non-original order): {x}') elif method == 'mt2': jdim = n + 3 p = pad_array(profits, jdim) @@ -195,8 +164,7 @@ def solve_single_knapsack( if verbose: logger.info(f'Method: "{method}"') logger.info(f'Total profit: {z}') - logger.info('Solution vector: ' - f'{x[:n][items_reverse_idx].tolist()}') + logger.info(f'Solution vector (non-original order): {x}') logger.info(f'Solution upper bound: {jub}') elif method == 'mt1r': jdim = n + 1 @@ -218,29 +186,9 @@ def solve_single_knapsack( if verbose: logger.info(f'Method: "{method}"') logger.info(f'Total profit: {z}') - logger.info('Solution vector: ' - f'{x[:n][items_reverse_idx].tolist()}') + logger.info(f'Solution vector (non-original order): {x}') else: raise ValueError(f'Given method "{method}" not known') - # Inverse items and knapsacks to original order - profits = profits[items_reverse_idx] - weights = weights[items_reverse_idx] - x = np.array(x)[items_reverse_idx] - - res = process_results(profits, weights, capacity, x) - - if verbose: - knapsack_results = ( - res - .groupby('knapsack_id') - .agg( - capacity_used=('weight', 'sum'), - capacity_available=('knapsack_capacity', 'first'), - profit=('profit', 'sum'), - items=('item_id', 'unique') - ) - ) - logger.info(f'Results by knapsack_id:\n{knapsack_results.to_string()}') - - return res + # Inverse items to original order + return np.array(x)[:n][items_reorder_reverse] diff --git a/pyproject.toml b/pyproject.toml index 4cd8b7b..d33f196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,7 @@ classifiers = [ ] requires-python = ">=3.8, <3.12" dependencies = [ - "numpy>=1.13", - "pandas>=1.0", + "numpy>=1.13" ] dynamic = ["version"] diff --git a/requirements.txt b/requirements.txt index 1a118f8..70f5b86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -numpy==1.23.1 -pandas==1.4.3 \ No newline at end of file +numpy==1.23.1 \ No newline at end of file diff --git a/tests/test__bounded.py b/tests/test__bounded.py new file mode 100644 index 0000000..3503376 --- /dev/null +++ b/tests/test__bounded.py @@ -0,0 +1,186 @@ +"""Test cases for single knapsack problems.""" + + +import numpy as np +import pytest + +from mknapsack._bounded import solve_bounded_knapsack +from mknapsack._exceptions import FortranInputCheckError + +from tests.utils import get_id + + +bounded_knapsack_case_small = { + 'case': 'small', + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80], + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76], + 'n_items': [1] * 10, + 'capacity': 190, + 'total_profit': 407, + 'solution': [0, 1, 1, 1, 1, 1, 1, 0, 0, 0] +} + +bounded_knapsack_case_small_reverse = { + 'case': 'small-reverse', + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80][::-1], + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76][::-1], + 'n_items': ([1] * 10)[::-1], + 'capacity': 190, + 'total_profit': 407, + 'solution': [0, 1, 1, 1, 1, 1, 1, 0, 0, 0][::-1] +} + +bounded_knapsack_case_medium = { + 'case': 'medium', + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80] * 5, + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76] * 5, + 'n_items': [1, 2] * 25, + 'capacity': 190 * 2, + 'total_profit': 1543, + 'solution': [0, 2, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 2, 1, 2, 0, 0, 0, 0, 0, 0, 0, 2, 1, 2, 0, 0, 0, 0, 0, 0, 0, 2, + 1, 1, 0, 0, 0, 0, 0, 0] +} + +bounded_knapsack_case_large = { + 'case': 'large', + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80] * 100_000, + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76] * 100_000, + 'n_items': [1, 2, 3, 4, 5] * 200_000, + 'capacity': 190 * 500, + 'total_profit': None, + 'solution': None +} + +bounded_knapsack_success_cases = [ + {'method': 'mtb2', **bounded_knapsack_case_small, 'tolerance': 0.03}, + {'method': 'mtb2', **bounded_knapsack_case_small_reverse, + 'tolerance': 0.03}, + {'method': 'mtb2', **bounded_knapsack_case_medium, 'tolerance': 0.1}, + {'method': 'mtb2', 'method_kwargs': {'require_exact': 1}, + **bounded_knapsack_case_medium}, + {'method': 'mtb2', **bounded_knapsack_case_large} +] + +bounded_knapsack_fail_cases_base = [ + { + 'case': 'profit_weight_mismatch', + 'methods': ['mtb2'], + 'profits': [1, 2, 3, 4, 5], + 'weights': [1, 2, 3, 4], + 'n_items': [1, 2, 2, 1, 1], + 'capacity': 9, + 'fail_type': ValueError + }, + { + 'case': 'only_one_item', + 'methods': ['mtb2'], + 'profits': [1], + 'weights': [1], + 'n_items': [1], + 'capacity': 9, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'profit_lte_0', + 'methods': ['mtb2'], + 'profits': [1, 2, 3, 4, 0], + 'weights': [1, 2, 3, 4, 5], + 'n_items': [1, 2, 2, 1, 1], + 'capacity': 9, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'weight_lte_0', + 'methods': ['mtb2'], + 'profits': [1, 2, 3, 4, 5], + 'weights': [1, 2, 3, 4, 0], + 'n_items': [1, 2, 2, 1, 1], + 'capacity': 9, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'capacity_lte_0', + 'methods': ['mtb2'], + 'profits': [1, 2, 3, 4, 0], + 'weights': [1, 2, 3, 4, 5], + 'n_items': [1, 2, 2, 1, 1], + 'capacity': 0, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'item_type_total_weight_gte_capacity', + 'methods': ['mtb2'], + 'profits': [1, 2, 3, 4, 5], + 'weights': [1, 2, 3, 4, 5], + 'n_items': [1, 2, 2, 1, 2], + 'capacity': 9, + 'fail_type': FortranInputCheckError + }, + { + 'case': 'total_weight_le_min_capacity', + 'methods': ['mtb2'], + 'profits': [1, 2, 3, 4, 5], + 'weights': [1, 2, 3, 4, 5], + 'n_items': [1, 2, 2, 1, 1], + 'capacity': 100, + 'fail_type': FortranInputCheckError + } +] + +bounded_knapsack_fail_cases = [ + {**case, 'method': method} + for case in bounded_knapsack_fail_cases_base + for method in case['methods'] +] + + +@pytest.mark.parametrize('params', bounded_knapsack_success_cases, + ids=get_id) +def test_solve_bounded_knapsack(params): + # Get function arguments from params + profits = params['profits'] + weights = params['weights'] + capacity = params['capacity'] + n_items = params['n_items'] + + total_profit = params['total_profit'] + solution = params['solution'] + tolerance = params.get('tolerance', 0) + + func_kwargs = dict( + profits=profits, + weights=weights, + capacity=capacity, + n_items=n_items + ) + for opt_param in ['method', 'method_kwargs', 'verbose']: + if opt_param in params: + func_kwargs[opt_param] = params[opt_param] + + # Run algorithm + res = solve_bounded_knapsack(**func_kwargs) + + assert isinstance(res, np.ndarray) + assert len(res) == len(profits) + + # Ensure no overweight in knapsack + assert (np.array(weights) * res).sum() <= capacity + + # Ensure profit within given limits + res_profit = (np.array(profits) * res).sum() + 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', bounded_knapsack_fail_cases, ids=get_id) +def test_solve_bounded_knapsack_fail(params): + del params['case'], params['methods'] + fail_type = params.pop('fail_type') + with pytest.raises(fail_type): + solve_bounded_knapsack(**params) diff --git a/tests/test__multiple.py b/tests/test__multiple.py index 8a3cece..3522ee8 100644 --- a/tests/test__multiple.py +++ b/tests/test__multiple.py @@ -1,20 +1,22 @@ """Test cases for multiple knapsack problems.""" -import pandas as pd +import numpy as np import pytest from mknapsack._multiple import solve_multiple_knapsack from mknapsack._exceptions import FortranInputCheckError +from tests.utils import get_id + multiple_knapsack_case_small = { 'case': 'small', - 'profits': [78, 35, 89, 36, 94, 75, 74, 100, 80, 16], - 'weights': [18, 9, 23, 20, 59, 61, 70, 75, 76, 30], - 'capacities': [90, 100], + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80], + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76], + 'capacities': [100, 90], 'total_profit': 407, - 'solution': [2, 1, 2, 1, 2, 1, 0, 0, 0, 0] + 'solution': [0, 1, 2, 1, 2, 1, 2, 0, 0, 0] } multiple_knapsack_case_medium = { @@ -23,8 +25,8 @@ 'weights': [18, 9, 23, 20, 59, 61, 70, 75, 76, 30] * 5, 'capacities': [90, 100] * 2, 'total_profit': 1213, - 'solution': [1, 0, 3, 0, 0, 0, 0, 0, 0, 0, 3, 4, 2, 2, 4, 0, 0, 0, 0, 0, 3, - 2, 4, 1, 0, 0, 0, 0, 0, 0, 2, 4, 3, 2, 0, 0, 0, 0, 0, 0, 3, 1, + 'solution': [1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 4, 3, 3, 4, 0, 0, 0, 0, 0, 2, + 3, 4, 1, 0, 0, 0, 0, 0, 0, 3, 4, 2, 3, 0, 0, 0, 0, 0, 0, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0] } @@ -55,7 +57,7 @@ 'method': 'mtm', 'method_kwargs': {'max_backtracks': 20}, **multiple_knapsack_case_small, - 'tolerance': 0.97 + 'tolerance': 0.03 }, { 'method': 'mthm', @@ -65,17 +67,16 @@ 'method': 'mthm', 'method_kwargs': {'call_stack': 0}, **multiple_knapsack_case_small, - 'tolerance': 0.85 + 'tolerance': 0.15 }, { 'method': 'mthm', **multiple_knapsack_case_medium, - 'tolerance': 0.97 + 'tolerance': 0.03 }, { 'method': 'mthm', - **multiple_knapsack_case_large, - 'tolerance': None + **multiple_knapsack_case_large } ] @@ -176,47 +177,46 @@ ] -def get_id(params): - method = str(params.get('method', 'NotSet')) - method_kwargs = str(params.get('method_kwargs', 'NotSet')) - case = str(params.get('case', 'NotSet')) - verbose = str(params.get('verbose', 'NotSet')) - return f'{method}-{method_kwargs}-{verbose}-{case}' - - @pytest.mark.parametrize('params', multiple_knapsack_success_cases, ids=get_id) def test_solve_multiple_knapsack(params): + # Get function arguments from params + profits = params['profits'] + weights = params['weights'] + capacities = params['capacities'] + + total_profit = params['total_profit'] + solution = params['solution'] + tolerance = params.get('tolerance', 0) + func_kwargs = dict( - profits=params['profits'], - weights=params['weights'], - capacities=params['capacities'] + profits=profits, + weights=weights, + capacities=capacities ) for opt_param in ['method', 'method_kwargs', 'verbose']: if opt_param in params: func_kwargs[opt_param] = params[opt_param] + # Run algorithm res = solve_multiple_knapsack(**func_kwargs) - assert isinstance(res, pd.DataFrame) - assert len(res) == len(params['profits']) + assert isinstance(res, np.ndarray) + assert len(res) == len(profits) - total_profit = params['total_profit'] - solution = params['solution'] - tolerance = params.get('tolerance', 1) + # Ensure no overweight in knapsacks + weights = np.array(weights) + for i, c in enumerate(capacities): + assert weights[res == i + 1].sum() <= c # Ensure profit within given limits + res_profit = np.array(profits)[res > 0].sum() if total_profit is not None: - min_allowed_profit = tolerance * total_profit - test_total_profit = res.query('assigned')['profit'].sum() - if tolerance == 1: - assert test_total_profit == min_allowed_profit - else: - assert test_total_profit >= min_allowed_profit - - # Ensure global optimum when tolerance = 1 - if solution is not None and tolerance == 1: - test_solution = tuple(res['knapsack_id'].to_list()) - assert tuple(solution) == test_solution + 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', multiple_knapsack_fail_cases, ids=get_id) diff --git a/tests/test__single.py b/tests/test__single.py index a5f2166..0de87b4 100644 --- a/tests/test__single.py +++ b/tests/test__single.py @@ -1,20 +1,31 @@ """Test cases for single knapsack problems.""" -import pandas as pd +import numpy as np import pytest from mknapsack._single import solve_single_knapsack from mknapsack._exceptions import FortranInputCheckError +from tests.utils import get_id + single_knapsack_case_small = { 'case': 'small', - 'profits': [78, 35, 89, 36, 94, 75, 74, 100, 80, 16], - 'weights': [18, 9, 23, 20, 59, 61, 70, 75, 76, 30], + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80], + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76], + 'capacity': 190, + 'total_profit': 407, + 'solution': [0, 1, 1, 1, 1, 1, 1, 0, 0, 0] +} + +single_knapsack_case_small_reverse = { # Ensure ordering works + 'case': 'small-reverse', + 'profits': [16, 78, 35, 89, 36, 94, 75, 74, 100, 80][::-1], + 'weights': [30, 18, 9, 23, 20, 59, 61, 70, 75, 76][::-1], 'capacity': 190, 'total_profit': 407, - 'solution': [1, 1, 1, 1, 1, 1, 0, 0, 0, 0] + 'solution': [0, 1, 1, 1, 1, 1, 1, 0, 0, 0][::-1] } single_knapsack_case_medium = { @@ -49,53 +60,18 @@ } single_knapsack_success_cases = [ - { - 'method': 'mt1', - **single_knapsack_case_small - }, - { - 'method': 'mt1', - **single_knapsack_case_small, - 'verbose': True - }, - { - 'method': 'mt1', - **single_knapsack_case_medium - }, - { - 'method': 'mt2', - **single_knapsack_case_small, - 'tolerance': 1 - }, - { - 'method': 'mt2', - **single_knapsack_case_medium, - 'tolerance': 0.99 - }, - { - 'method': 'mt2', - 'method_kwargs': {'require_exact': 1}, - **single_knapsack_case_medium, - }, - { - 'method': 'mt2', - **single_knapsack_case_large - }, - { - 'method': 'mt1r', - **single_knapsack_case_small, - 'tolerance': 1 - 1e-07 - }, - { - 'method': 'mt1r', - **single_knapsack_case_medium, - 'tolerance': 1 - 1e-07 - }, - { - 'method': 'mt1r', - **single_knapsack_case_medium_real, - 'tolerance': 1 - 1e-07 - } + {'method': 'mt1', **single_knapsack_case_small}, + {'method': 'mt1', **single_knapsack_case_small, 'verbose': True}, + {'method': 'mt1', **single_knapsack_case_small_reverse}, + {'method': 'mt1', **single_knapsack_case_medium}, + {'method': 'mt2', **single_knapsack_case_small, 'tolerance': 0}, + {'method': 'mt2', **single_knapsack_case_medium, 'tolerance': 0.01}, + {'method': 'mt2', 'method_kwargs': {'require_exact': 1}, + **single_knapsack_case_medium}, + {'method': 'mt2', **single_knapsack_case_large}, + {'method': 'mt1r', **single_knapsack_case_small, 'tolerance': 1e-07}, + {'method': 'mt1r', **single_knapsack_case_medium, 'tolerance': 1e-07}, + {'method': 'mt1r', **single_knapsack_case_medium_real, 'tolerance': 1e-07} ] single_knapsack_fail_cases_base = [ @@ -164,6 +140,7 @@ 'fail_type': ValueError } ] + single_knapsack_fail_cases = [ {**case, 'method': method} for case in single_knapsack_fail_cases_base @@ -171,47 +148,44 @@ ] -def get_id(params): - method = str(params.get('method', 'NotSet')) - method_kwargs = str(params.get('method_kwargs', 'NotSet')) - case = str(params.get('case', 'NotSet')) - verbose = str(params.get('verbose', 'NotSet')) - return f'{method}-{method_kwargs}-{verbose}-{case}' - - @pytest.mark.parametrize('params', single_knapsack_success_cases, ids=get_id) def test_solve_single_knapsack(params): + # Get function arguments from params + profits = params['profits'] + weights = params['weights'] + capacity = params['capacity'] + + total_profit = params['total_profit'] + solution = params['solution'] + tolerance = params.get('tolerance', 0) + func_kwargs = dict( - profits=params['profits'], - weights=params['weights'], - capacity=params['capacity'] + profits=profits, + 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_single_knapsack(**func_kwargs) - assert isinstance(res, pd.DataFrame) - assert len(res) == len(params['profits']) + assert isinstance(res, np.ndarray) + assert len(res) == len(profits) - total_profit = params['total_profit'] - solution = params['solution'] - tolerance = params.get('tolerance', 1) + # Ensure no overweight in knapsack + assert np.array(weights)[res > 0].sum() <= capacity # Ensure profit within given limits + res_profit = np.array(profits)[res > 0].sum() if total_profit is not None: - min_allowed_profit = tolerance * total_profit - test_total_profit = res.query('assigned')['profit'].sum() - if tolerance == 1: - assert test_total_profit == min_allowed_profit - else: - assert test_total_profit >= min_allowed_profit - - # Ensure global optimum when tolerance = 1 - if solution is not None and tolerance == 1: - test_solution = tuple(res['knapsack_id'].to_list()) - assert tuple(solution) == test_solution + 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', single_knapsack_fail_cases, ids=get_id) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..f9aaf5e --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,8 @@ +"""Module for helper functions for testing.""" + + +def get_id(params): + name = '' + for key in ['method', 'method_kwargs', 'case', 'verbose']: + name += f'-{params.get(key, "NotSet")}' + return name.strip('-')