3

I am using python as a programming language and implementing a constraint of grouping similar lengths together satisfying the linear programming. Refer to the code shown below

import pulp
from itertools import product
import pandas as pd
import numpy as np

# DataFrame of item, weight, and length
df_updated = pd.DataFrame([['item1', 10, 'A'], ['item2', 20, 'B'],  ['item3', 20, 'C'], 
        ['item4', 20, 'B'], ['item5',10, 'A'], ['item6',10, 'B']], 
        columns = ['itemname', 'QuantityToGroup', 'Length'])

# Max weightage per bin
max_weight = 40

# Max bin to use
min_bins = int(np.ceil(round((df_updated['QuantityToGroup'].sum() / max_weight))))
max_bins = 3

problem = pulp.LpProblem("Grouping_lengths", pulp.LpMinimize)

# Variable to check, if we are using the bin or not
bin_used = pulp.LpVariable.dicts('is_bin_used', range(max_bins), lowBound=0, upBound=1, cat='Binary')

# Possible combinations to put the item in the bin
possible_item_in_bin = [(item_index, bin_num) for item_index, bin_num in product(df_updated.index, range(max_bins))]
item_in_bin = pulp.LpVariable.dicts('is_item_in_bin', possible_item_in_bin, lowBound=0, upBound=1, cat = 'Binary')

# Only one item in each bin
for item_index in df_updated.index:
    problem += pulp.lpSum([item_in_bin[item_index, bin_index] for bin_index in range(max_bins)]) == 1, f"Ensure that item {item_index} is only in one bin"

# Sum of quantity grouped in each bin must be less than max weight
for bin_index in range(max_bins):
    problem += pulp.lpSum(
            [item_in_bin[item_index, bin_index] * df_updated.loc[item_index, 'QuantityToGroup'] for item_index in df_updated.index]
        ) <= max_weight * bin_used[bin_index], f"Sum of items in bin {bin_index} should not exceed max weight {max_weight}"

# Length Constraints
lengths = list(df_updated.Length.unique())
for length in lengths:
    items_n = df_updated.index[df_updated['Length'] == length].tolist()
    if len(items_n) > 1:
        for bin in range(max_bins - 1):
            first_index = items_n[0]
            for item in items_n[1:]:
                constr = pulp.LpConstraint(item_in_bin[first_index, bin] - item_in_bin[item, bin], sense = 0, rhs = 0, name = f"place item {item} in bin {bin} if length number {length} is chosen for this bin")
                problem += constr

# Objective function to minimize bins used
problem += pulp.lpSum(bin_used[bin_index] for bin_index in range(max_bins)), "Objective: Minimize Bins Used"

problem.solve(pulp.PULP_CBC_CMD(msg = False))


for val in problem.variables():
    if val.varValue == 1:
       print(val.name, val.varValue)

For the given input code is unable to group items of length B as the total weight for length B is (item 2 -> 20, item 4 -> 20, and item 6 -> 10) 50 which is greater than max weight 40. The code is working as expected.

But I have to make the length constraint elastic, which means it is okay to violate the constraint but the penalty should be added if the constraint is violated. I have explored Elastic Constraints which I think are exactly for my kind of requirement.

But I am facing an issue to implement them holding the linearity of the problem. Do I have to formulate my constraint in a different manner? Any help is appreciated.

Possible expected Output from the code making sure the objective of minimizing the wastage is respected and constraint is followed. If the constraint is not followed then the penalty is added.

# item 1 (A - 10), item 5 (A - 10), item3 (C - 20) on 1st bin. 
# item 2 (B) and item 4 (B) on 2nd bin.
# item 6 (B - 10) on 3rd bin

I have also tried alternative ways to formulate the length constraint section as shown below:

# Length Variable
lengths = list(df_updated.length.unique())

# Possible combinations to put the lengths in the bin
possible_length_in_bin = [(length, bin_num) for length, bin_num in product(range(len(lengths)), range(max_bins))]

# Constraint to group similar lengths together on same bin
length_in_bin = pulp.LpVariable.dicts('LengthInBin', possible_length_in_bin, cat = 'Binary')
for item, length, bins_index in product(df_updated.index, range(len(lengths)), range(max_bins)):
    problem += pulp.lpSum(item_in_bin[(item, bins_index)] == length_in_bin[(length, bins_index)]), (f"Only place item {item} in bin {bins_index} if length number {length} is chosen for this bin")

The rest of the section remains the same as above. But still, the solution doesn't return desired results.

Tavish Aggarwal
  • 1,020
  • 3
  • 22
  • 51
  • I think elastic constraints may not apply directly in your case because they create a model with an objective function that penalizes *only* constraint violation. You can do your own version of "elastic" constraints, though: for each constraint that is allowed to be violated create a new continuous variable in [0,inf[ and subtract that variable from the left-hand side of the constraint. Then also add that variable to the objective function with a non-zero penalty term. This allows you to balance the penalty for violating a constraint with the original objective function. – Daniel Junglas Aug 22 '22 at 14:53
  • @DanielJunglas I got the approach you are suggesting. Could you answer the code snippet you are suggesting? – Tavish Aggarwal Aug 23 '22 at 07:38
  • I suggest to do as follows: 1. Create a list `aux = list()`. 2. Whenever you need to create a constraint that might be violated, create a new variable `relax = pulp.LpVariable(lowBound=0)`. 3. Subtract `relax` from the left-hand side of the constraint and append it to `aux`. 4, When you build the objective, add `penalty * pulp.lpSum(aux)` where `penalty` is an appropriate factor that has to be chosen by you. – Daniel Junglas Aug 26 '22 at 06:00

1 Answers1

2

Here is a solution that I think answers the mail. It is still linear. You need to introduce a couple of variables to count things such as the number of different lengths in a particular bin.

Some of those variables require a "big M" type constraint to link a binary variable to a summation.

Then with that variable in hand, you can add a small (or large?) penalty for "overloading" a bin with more than one length type.

Looking at this again, the tot_bins variable could be removed and just replaced by the lpSum(bin_used[b] for b in bins) anywhere, but it is clear as written.

I reformatted the code with black, which I'm not sure I'm fond of yet, but at least it is consistent. :)

Code

import pulp
from itertools import product
import pandas as pd
import numpy as np

# DataFrame of item, weight, and length
df_updated = pd.DataFrame(
    [
        ["item1", 10, "A"],
        ["item2", 20, "B"],
        ["item3", 20, "C"],
        ["item4", 20, "B"],
        ["item5", 10, "A"],
        ["item6", 10, "B"],
    ],
    columns=["itemname", "QuantityToGroup", "Length"],
)
lengths = list(df_updated.Length.unique())

# Max weightage per bin
max_weight = 40

# big M for number of items
big_M = len(df_updated)

# Max bin to use
min_bins = int(np.ceil(round((df_updated["QuantityToGroup"].sum() / max_weight))))
max_bins = 3
bins = list(range(max_bins))

problem = pulp.LpProblem("Grouping_lengths", pulp.LpMinimize)

# Variable to check, if we are using the bin or not
bin_used = pulp.LpVariable.dicts(
    "is_bin_used", bins, cat="Binary"
)

# Indicator that items of dimension d are located in bin b:
loaded = pulp.LpVariable.dicts(
    "loaded", [(d, b) for d in lengths for b in bins], cat="Binary"
)

# the total count of bins used
tot_bins = pulp.LpVariable("bins_used")

# the total count of overloads in a bin.  overload = (count of dimensions in bin) - 1
overload = pulp.LpVariable.dicts("bin_overloads", bins, lowBound=0)

# Possible combinations to put the item in the bin
possible_item_in_bin = [
    (item_index, bin_num) for item_index, bin_num in product(df_updated.index, bins)
]
item_in_bin = pulp.LpVariable.dicts(
    "is_item_in_bin", possible_item_in_bin, cat="Binary"
)

# Force each item to be loaded...
for item_index in df_updated.index:
    problem += (
        pulp.lpSum([item_in_bin[item_index, bin_index] for bin_index in bins]) == 1,
        f"Ensure that item {item_index} is only in one bin",
    )

# Sum of quantity grouped in each bin must be less than max weight
for bin_index in bins:
    problem += (
        pulp.lpSum(
            [
                item_in_bin[item_index, bin_index]
                * df_updated.loc[item_index, "QuantityToGroup"]
                for item_index in df_updated.index
            ]
        )
        <= max_weight * bin_used[bin_index],
        f"Sum of items in bin {bin_index} should not exceed max weight {max_weight}",
    )

# count the number of dimensions (lengths) in each bin
for b in bins:
    for d in lengths:
        problem += loaded[d, b] * big_M >= pulp.lpSum(
            item_in_bin[idx, b] for idx in df_updated.index[df_updated.Length == d]
        )

# attach the "bin used" variable to either the "loaded" var or "item in bin" var...
for b in bins:
    problem += bin_used[b] * big_M >= pulp.lpSum(
        item_in_bin[idx, b] for idx in df_updated.index
    )

# count total bins used
problem += tot_bins >= pulp.lpSum(bin_used[b] for b in bins)

# count the overloads by bin
for b in bins:
    problem += overload[b] >= pulp.lpSum(loaded[d, b] for d in lengths) - 1


# Objective function to minimize bins used, with some small penalty for total overloads
usage_wt = 1.0
overload_wt = 0.2

problem += (
    usage_wt * tot_bins + overload_wt * pulp.lpSum(overload[b] for b in bins),
    "Objective: Minimize Bins Used, penalize overloads",
)

problem.solve(pulp.PULP_CBC_CMD(msg=False))
status = pulp.LpStatus[problem.status]
assert(status=='Optimal')  # <--- always ensure this before looking at result if you don't print solve status


print(f"total bins used: {tot_bins.varValue}")
print("bin overloads:")
for b in bins:
    if overload[b].varValue > 0:
        print(f"    bin {b} has {overload[b].varValue} overloads")

for idx, b in possible_item_in_bin:
    if item_in_bin[idx, b].varValue == 1:
        print(
            f"load {df_updated.itemname.iloc[idx]}/{df_updated.Length.iloc[idx]} in bin {b}"
        )

Result:

total bins used: 3.0
bin overloads:
    bin 0 has 1.0 overloads
load item1/A in bin 0
load item2/B in bin 2
load item3/C in bin 1
load item4/B in bin 2
load item5/A in bin 0
load item6/B in bin 0
AirSquid
  • 10,214
  • 2
  • 7
  • 31
  • The solution provided is working in 90% of scenarios but not for all. For e.g. df_updated = pd.DataFrame( [ ["item1", 10, "A"], ["item2", 10, "B"], ["item3", 10, "C"], ["item4", 10, "D"] ], columns=["itemname", "QuantityToGroup", "Length"], ) solution returns infeasible with max bins as 3 and max weight as 40. This should not be the case. The least possible objective value would be 1.6 but now in this case it is 2.4 – Tavish Aggarwal Sep 24 '22 at 13:49
  • Caught me at the computer... Did you change something else? I substituted the exact code in your comment above for `df_updated` and it runs fine and gives: total bins used: 1.0 bin overloads: bin 0 has 3.0 overloads load item1/A in bin 0 load item2/B in bin 0 load item3/C in bin 0 load item4/D in bin 0 – AirSquid Sep 24 '22 at 13:52
  • lack of formatting there, but it uses 1 bin (as expected) with 3 overloads in the bin and everything assigned to bin 0. – AirSquid Sep 24 '22 at 13:54
  • Correct. I didn't reset the jupyter notebook. But, yes it works. – Tavish Aggarwal Sep 24 '22 at 14:02
  • 1
    awesome. (notebook code execution can bite ya sometimes....been there!). LMK if there is some aspect of the problem that isn't covered or unclear. GL! – AirSquid Sep 24 '22 at 14:04
  • @AirSquid, could you explain how big_M work exactly? How does multiply by big_M allows you to "populate" loaded[d,b] ? – redbaron Jul 22 '23 at 12:20
  • 1
    Sure. The purpose of the `loaded` variable is to indicate if *any* items of length `d` are in bin `b`. The objective is trying to lower the `overload` variable, so there is "downward pressure" on `loaded` to try to minimize the loading of similar items into multiple bins. big_M is just a "large number" so that if any item of length `d` is loaded in a bin as indicated by `item_in_bin` variable, it is going to force `loaded` to be one. If 3 items are loaded, it is still 1, indicating that there are items of that dimension in the bin. You can google "Big M linear programming" to see examples – AirSquid Jul 23 '23 at 19:42
  • @redbaron LMK if that makes sense. – AirSquid Jul 23 '23 at 19:42
  • TLDR; `x*big_M >=` is a trick to force `x` to be equal to one if it can't be zero. It works because even value of one makes LHS bigger than any feasible value on RHS. @AirSquid, many thanks. I was looking for "Big M" ,but most search results were coming up with a trick in simplex method when finding solution. When looking for linear programming there are a lot of results for various algorithms, but I had much less luck fininding LP problem formulation guides. If there are any resources (online or books) you can recomment, I'll happily accept that. – redbaron Jul 24 '23 at 21:05