Path: blob/main/Lessons/Lesson 07 - Global Optimization 2/extras/Lesson_07_Load_Balancing_With_Constraints.ipynb
871 views
Load Balancing With Contraints Example
Note: Make sure you've already read Part 1, in which we do this problem without constraints. We'll only be explaining the new bits here.
In this example we're going to show how you could use various approaches to solve a constrained load balancing problem.
For this problem, we're talking execution times on computer processors, with total execution time on certain processors limited to a certain amount. You might see this happen when one processor needs to "reserve" processing cycles for some other job not in our load balancing list.
We can describe it as:
given a list of execution times, divide them to be executed on processors so that the total execution time on each processor is as close to the same as possible, while constrained processors are under execution time limit.
There are two kinds of constraints we can implement with our metaheuristic algorithms:
hard constraints
soft constraints
We'll start with hard constraints.
Hard Constraint
A hard constraint is a constraint which rejects any solution that doesn't meet our specifications. We've seen these before in Pyomo when we build our lists of constraints or our bounds.
We can use hard constraints with some of our metaheuristics methods. We'll use hard constraints with greedy local search and simulated annealing.
There are two ways to implement hard constraints - directly in the loop of our hand-coded solutions, or in the move function. We'll look at both approaches with our greedy local search.
Hard Constraint - Objective Function
For a hard constraint, our objective function remains identical. (If you need a refresher on what the objective function is doing, please see the Lesson_05_Load_Balancing notebook.)
Hard Constraint - Directly In the loop Move Function
When we implement our hard constraint directly in our loop for greedy local search, the move function remains the same as it was without constraints.
Hard Constraint - Enforced in the Move Function
But, we can put the hard constraint directly in our move function, instead. We'll implement it by first completing the move, then checking to see if the new assignments meet our constraints. If they do not, we'll return the original assignments. If they do, we'll return the new assignments.
To do this, we'll need to pass in two additional parameters:
conproc - the list of constrained processors
conmax - the list of max total processing time allowed on each constrained processor
Let's look at the function first.
Let's see what the constrained move function looks like with a simple problem. We'll create some sample data, and run the reassign_one_constrained() function, constraining processor 0 to a total processing time of 10. You can uncomment the print statements in the function to see what's happening inside the function, or you can just compare what goes in with what comes out below. Note that if we start with more than 10 on constrained processor (0), we'll still end up with more than 10 on it if our reassignment doesn't "fix" it.
Greedy Local Search - Hard Constraint In the Loop
Let's see what it looks like to use the original function in a greedy local search, and enforce the hard constraint right in the loop.
We'll also track whether the algorithm ever finds a solution that meets the constraints. It's possible with a hard constraint that we never find a solution that works.
Greedy Local Search - Hard Constraint In the Move Function
Next, let's implement the hard constraint directly in our move function (reassign_one_constrained).
Again, to use that, we need our two additional parameters, so we'll update our load_balance_local function to take in 2 additional parameters:
conproc - a list of the processors to constrain
conmax - a list of the max times on each processor.
We'll again track whether the algorithm ever finds a solution that meets the constraints.
Let's run each of these with a small number of processors and a small number of job execution times. First let's generate some random data and see what the time on each processor would be if it loads were completely balanced.
Running local search with constraints
Let's start with setting processor 0 to be constrained to a max processing time of 1100. Run this code several times. How often do you get convergence?
What's happening?
This isn't working very well, is it? Why not? Well, let's say we start with 1400 on our constrained processor and our largest processing time is 200. 1400-200 = 1200. 1200 is still larger than our constrained solution allows. In other words - there's no single move that can find a feasible solution.
How do we fix it?
There are a couple of approaches we could take. We've already seen one approach in prior lesson - using multi-start. We could wrap our load_balance_local_function_constraint or our load_balance_local_loop_constraint in a multi-start function and cross our fingers and hope that at least one of our starts randomly starts with a feasible solution. With some algorithms, that's our only choice to avoid getting stuck in a local optimum.
But for our problem, we can use another approach. We can start from a random, but feasible solution. We can do this in 2 ways - we can put all of our processes on a single processor and let the algorithm "spread them out" or we can randomly distribute our processes across the unconstrained processors.
We'll actually write code that handles both, because with only 3 processors, if we constrain 2, we only have a single processor to start with. But, for larger problems, we'll randomly distribute across the unconstrained processors.
We'll need a new version of our load_balance_local function.
Trying again
Now let's try our code and see if we get feasible solutions. Run this multiple times.
So much better! We consisently find a feasible solution.
What if we wanted to constrain 2 of our processors? Easy! We just add to our conproc and conmax lists. This time, let's constrain processor 0 to a max time of 1200 and processor 1 to a max time of 1100. Again, run this code multiple times and see how often the algorithm converges.
Simulated Annealing - By Hand - Hard Constraints
We can take the same hard constraint approach with our hand-coded simulated annealing problem. Once again, we'll add 2 parameters to our custom_simanneal function:
conproc - a list of the processors to constrain
conmax - a list of the max times on each processor.
We'll start with a feasible solution (no processes on the constrained processor).
And once again we'll pass back a convergence variable to let us know if we ever found a solution that matched our constraints.
Personally, I think it's neater and cleaner to use the reassign_one_constrained function. So we'll use that one for our moves.
We'll use the same set of jobs from the previous example so you can compare.
The simanneal Package - Hard Constraints - No External Functions
We can also use a hard constraint with the simanneal package. Let's see what it looks like if don't use external functions. To do this, you add your code within the package's move and energy functions. To use a hard constraint in simanneal, you'd enforce the constraint in the move function, just like we did with our hand-coding.
We again need our two extra variables:
conproc - a list of the processors to constrain
conmax - a list of the max times on each processor.
But this time we'll pass them into the initialization function of the simanneal package.
To start with a feasible solution, we'll need to generate it outside the package and pass in the initial assignment.
Let's see what that looks like.
The simanneal Package - Hard Constraints - External Functions
The simanneal package will also work with external functions. Remember, we've already written nice, self-contained functions that correspond to simanneal's functions.
move = reassign_one_constrained
energy = balance_metric
We can just plop those external functions in to simanneal's functions and run our code.
Note: Simanneal evaluates the problem space before running. If your constraint is set too low, simanneal will print the first pink line, and then just hang. If you're playing with this and it gets stuck, you'll need to restart your kernel and loosen your constraints.
Soft-Constraints
As we've seen, sometimes with hard constraints you can fail to get a feasible solution, and we can't even get closer to a feasible solution because we're rejecting any option that doesn't meet the constraint. Soft constraints fix that problem. We won't always get a solution that meets the constraint, but the algorithm will at least have a chance to get closer to an optimal solution. The Big M approach is a type of soft constraint.
In the metaheuristic algorithms we're exploring, soft constraints are implemented in the objective function. Instead of rejecting a solution outright, a penalty is incorporated. For a minimization problem, a positive number is added when the constraint isn't met. For a maximization problem, a negative number is added.
In our code, we're adding a multiplier to the penalty. The larger the multipler, the "harder" the soft constraint. We'll add the penalty as a parameter so we can adjust the penalty on the fly.
Let's look at what this would look like with a hand-solved problem.
We'll keep our original objective function (balanced_metric), but we'll add a new wrapper function (balanced_metric_constrained). This one will take in 3 additional parameters:
a list of constrained processors
a list of the max times on each processor
a penalty multiplier (integer)
Testing the Soft Constraint
We'll test our two functions with some hand-coded assignments. We'll use 9 jobs on 3 processors. First we'll look at them as an unconstrained, perfectly balanced problem.
Now we'll add some constraints. Note that neither our times nor assignments are changing. But, we're essentially changing the target for some of our processors. We're going to set processor 0 to a max limit of 10 and a penalty of 5.
What happens if we increase the penalty to 10?
With the constraint in place, what was a completely balanced solution no longer looks so great. What would happen if we switch our assignments around some?
Here, we've met our constraint, so our unconstrained and constrained balance metrics match.
The simanneal Package - Soft Constraint
With simanneal, instead of adding our hard constraint to the move() function, we'd add our soft constraint to the energy() function. We'll need to pass in the penalty multiplier at initialization.
Let's generate some more test data and run our soft constraint version of simanneal.
With completely balanced loads, we'd have 1220-ish on each processor. We'll set processor 0's constraint to 1100. Run this code several times. Change up the penalty multiplier. How often do you meet the constraint? How balanced does the workload seem compared to our hard constraint version?
Genetic Algorithm with DEAP - Soft Constraints
Again with DEAP we'll do a soft constraint in our energy function. This requires a few small, but important changes. First, the things that stay the same.
Our create_individual() function
Our custom_ga() function
Neither of these change at all and we can just copy/paste the code from the load balance without constraints example.
Our balance_metric_tuple function does need to be updated. We need to take in the 3 additional parameters (do you have these down yet?):
conproc - a list of constrained processors
conmax - a list of the max times on each processor
penalty_multiplier - integer to control how "hard" the penalty is
The only other thing we'll need to change is how we set up our evaluate function. Most of this code is identical to what you've seen before. Note the one changed line
Increasing Problem Size
We used a very small problem to demonstrate each of the methods above. Now it's time to create a much larger problem and see how our algorithms perform.
We'll also set conproc and conmax to constrain 2 of our 10 processes.
Let's create a pandas dataframe for storing our results. We'll update this after each algorithm and at the end we can sort and compare to see which algorithm performed the best.
Baseline
Let's see what our baseline deviation from balanced loads is with a size this large. (Note, there's randomness here and some algorithms set their own baseline. But this should give us a general idea.)
We'll also create a new metric to get an apples to apples comparision of the scores, using the max constraints as a target and evenly distributed processes on unconstrained processors.
Greedy local search (hard constraint)
The only parameter we can fiddle with in our greedy local search is how many iterations we're willing to go with no improvement. Try changing the 5000 number to see if it gets better results
max_no_improve = 5000
Custom Simulated Annealing - Hard Constraint
For our custom simulated annealing, we can tweak the following parameters:
max_no_improve = 1000
temp = 500
alpha = .99
Try tweaking these parameters to see if you can get a better result
Simanneal Package - Hard Constraint
The only parameter you can tweak in the simanneal package is how long you're willing to wait. Try changing that to see if you can get a better result.
wait_time = .2
Simanneal Package - Soft Constraint - Penalty Multiplier 5
The only parameter you can tweak in the simanneal package is how long you're willing to wait. Try changing that to see if you can get a better result.
wait_time = .2
Simanneal Package - Soft Constraint - Penalty Multiplier 50
The only parameter you can tweak in the simanneal package is how long you're willing to wait. Try changing that to see if you can get a better result.
wait_time = .2
Simanneal Package - Soft Constraint - Penalty Multiplier 500
The only parameter you can tweak in the simanneal package is how long you're willing to wait. Try changing that to see if you can get a better result.
wait_time = .2
DEAP Genetic Algorithm - Penalty 5
DEAP has a lot of parameters to tweak. Try tweaking some of the following to see if you can get a better result.
pop_size = 200
crossover_prob = 0.3
mutation_prob = 0.5
max_gen = 2000
max_no_improve = 200
(Note: we need to repeat a lot of code when we're changing the problem space with DEAP. DEAP hard-codes the k and n in our functions when we set it up, so we need to essentially start from scratch. We've included all the necessary code without comments in the cell below.)
Warning: This code will be slow to run.
DEAP Genetic Algorithm - Penalty 50
To update just the penalty, we can unregister and register the evaluate function.
Warning: This code will be slow to run.
DEAP Genetic Algorithm - Penalty 500
To update just the penalty, we can unregister and register the evaluate function.
Warning: This code will be slow to run.
DEAP Genetic Algorithm - Feasible Start - Penalty 5
We found that the GA algorithm wasn't performing as well as we'd have thought it might. So we decided to try giving it a feasible start, even though we're using a soft constraint. Algorithms can only perform as well as we program them to perform. It's okay to try multiple options!
To create a feasible start, we needed a new version of our create individual function, and we need to register our individual class and assignments.
DEAP Genetic Algorithm - Feasible Start - Penalty 50
Once again, to just update the penalty, we only need to unregister and register in the evaluate function.
DEAP Genetic Algorithm - Feasible Start - Penalty 500
One last time...
Final Results
The results below are sorted by the constrained score - which identifies how close to both meeting the constraints and having the processing evenly distributed among the constrained processors. Based on these results, which of the algorithms performed "best" for you?