The multiple knapsack problem is linear, it can be solved efficiently with tools like linear integer programming.
But just because you’re stuffing items into bins doesn’t mean you have a knapsack problem. Sometimes you have a problem that very much looks like a knapsack problem until you’re 90% done coding it into OR-Tools when you realise you need to take the absolute value of something, and the true nature of your nonlinear predicament is revealed.
Enter derivative-free optimisation, where algorithms couldn’t care less about the linearity, differentiability, or really anything about your objective function. Nevergrad is a Python package containing a number of such algorithms that nicely solved my quasi knapsack problem.
Here’s my approach to using nevergrad to optimise a multiple knapsack problem.
I’ll use the same parameters as in the OR-Tools knapsack tutorial.
weights = [48, 30, 42, 36, 36, 48, 42, 42, 36, 24, 30, 30, 42, 36, 36]
values = [10, 30, 25, 50, 35, 30, 15, 40, 30, 35, 45, 10, 20, 30, 25]
bin_capacities = [100, 100, 100, 100, 100]
Instead tracking item-bin assignments using n_bins * n_items
boolean variables, I found it simpler and faster to use a single discrete categorical variable for each item. Each variable can take a value of 0 to 4 to represent assignment to the corresponding bin, and 5 to represent not being assigned to any bin.
In nevergrad Choice
represents an unordered categorical variable.
n_bins = len(bin_capacities)
n_items = len(weights)
bins = list(range(n_bins + 1))
variables = [ng.p.Choice(bins) for _ in range(n_items)]
The objective we want to maxmise is the value of the packed items. Nevergrad only does minimisation so the negative of the objective is returned.
def objective(*assignments):
value = 0
for i_item, assigned_bin in enumerate(assignments):
if assigned_bin != n_bins: # Ignore items not assigned to any bin.
value += values[i_item]
return -1 * value
Using a single categorical variable for each item has another benefit: it removes the need for a constraint that each item can only be assigned to at most one bin.
The only constraint we need is on bin capacity.
bin_weights = [0] * n_bins
for i_item, assigned_bin in enumerate(assignments):
if assigned_bin != n_bins: # Ignore items not assigned to any bin.
bin_weights[assigned_bin] += weights[i_item]
fill = [weight - capacity for weight, capacity in zip(bin_weights, bin_capacities)]
overfill = sum([max(x, 0) for x in fill])
Nevergrad does have a mechanism for constraints (register_cheap_constraint
). But for constraints like this that are expensive and use a lot of the same looping as the objective function, I found it worked better to add the constraint to the objective with a hefty penalty for violation.
def objective(*assignments):
value = 0
bin_weights = [0] * n_bins
for i_item, assigned_bin in enumerate(assignments):
if assigned_bin != n_bins: # Ignore items not assigned to any bin.
value += values[i_item]
bin_weights[assigned_bin] += weights[i_item]
# Constraint violation reduces value.
fill = [weight - capacity for weight, capacity in zip(bin_weights, bin_capacities)]
overfill = sum([max(x, 0) for x in fill])
value -= overfill * sum(bin_capacities)
return -1 * value
Nevergrad has a growing number of different solvers, I found DiscreteOnePlusOne
works much better than the others at this discrete optimisation problem.
A hefty budget (number of iterations) is needed to solve this problem. It’s much slower than linear optimisation algorithms.
parametrization = ng.p.Instrumentation(*variables)
optimizer = ng.optimizers.DiscreteOnePlusOne(
parametrization=parametrization,
budget=5000,
)
recommendation = optimizer.minimize(objective)
Running the optimisation takes a few minutes, and for a problem of this size does find the optimal solution of 395.
best_assignment = recommendation.value[0]
best_value = -1 * objective(*best_assignment)
print(best_value)
Output:
395
import nevergrad as ng
# Problem setup.
weights = [48, 30, 42, 36, 36, 48, 42, 42, 36, 24, 30, 30, 42, 36, 36]
values = [10, 30, 25, 50, 35, 30, 15, 40, 30, 35, 45, 10, 20, 30, 25]
bin_capacities = [100, 100, 100, 100, 100]
# Solver variables.
n_bins = len(bin_capacities)
n_items = len(weights)
bins = list(range(n_bins + 1))
variables = [ng.p.Choice(bins) for _ in range(n_items)]
def objective(*assignments):
value = 0
bin_weights = [0] * n_bins
for i_item, assigned_bin in enumerate(assignments):
if assigned_bin != n_bins: # Ignore items not assigned to any bin.
value += values[i_item]
bin_weights[assigned_bin] += weights[i_item]
# Constraint violation reduces value.
fill = [weight - capacity for weight, capacity in zip(bin_weights, bin_capacities)]
overfill = sum([max(x, 0) for x in fill])
value -= overfill * sum(bin_capacities)
return -1 * value
parametrization = ng.p.Instrumentation(*variables)
optimizer = ng.optimizers.DiscreteOnePlusOne(
parametrization=parametrization,
budget=5000,
)
recommendation = optimizer.minimize(objective)
best_assignment = recommendation.value[0]
best_value = -1 * objective(*best_assignment)
print(best_value)