pythonoptimizationpyomopygad

How to automatically convert objective expression from pyomo model to use as fitness_func for pygad?


I use pyomo to formulate optimization problems and tyically use solvers like for example IPOPT. Now I would like to apply metaheuristic solvers to those optimization problems. I have already heared of frameworks like pymoo or pygad that could be used for such purposes.

However, one always has to formulate the objective and constraints by hand. I would like to find an automated way to fetch the objective or constraints formulations from the pyomo ConcreteModel instance and use it to formulate the fitness_func for pygad.

For my MWE I just create a simple model with a simple objective (and try to pass this objective as a fitness_func to pygad GA), no constraints yet.

Minimum (not as desired) Working Example:

from pyomo import  environ as pe
import pygad

model = pe.ConcreteModel()
model.set1 = pe.Set(
    initialize=[i for i in range(10)]
)
model.param1 = pe.Param(
    model.set1,
    initialize={
        i: (i+1)**2 for i in model.set1
    },
    mutable=True
)
model.var = pe.Var(
    model.set1,
    domain=pe.PositiveReals,
    bounds=(2, 5)
)

def obj_rule(model):
    return sum(
        model.param1[i] * model.var[i]
        for i in model.set1
    )

model.obj = pe.Objective(
    rule=obj_rule,
    sense=pe.maximize
)

solver = pe.SolverFactory('IPOPT')
solver.solve(model)

solution_ipopt = model.obj.expr()

#=== pyomo model done, now comes everything pygad related ======================
n_vars = len(model.set1)

def fitness_func(instance, solution, solution_idx):
    # desired: something automated like
    # return model.obj.expr
    # (but that would be too easy and doesn't work)
    return sum(
        (i+1)**2 * solution[i] for i in range(1, n_vars)
    ) # yes, that works but is coded manually, I need something automated

ga_instance = pygad.GA(
    num_generations = 100,
    num_parents_mating = 10,
    fitness_func = fitness_func,
    sol_per_pop = 100,
    num_genes = n_vars,
    gene_type = float,
    gene_space = [{'low': 2., 'high': 5.} for _ in range(n_vars)],
    parent_selection_type = "tournament",
    crossover_type = "single_point",
    mutation_type = "random",
    mutation_percent_genes = 50,
    parallel_processing= ["thread", 8]
)

ga_instance.run()
solution, solution_fitness, solution_idx = ga_instance.best_solution()

In fitness_func I had to manually type sum((i+1)**2 * solution[i] for i in range(1, n_vars)) to build the fitness function according to the model.obj objective function. And I need an automated way (for example I found out, that model.obj.expr (without paranthesis!) gives me something that looks promising, but of course that would be too easy and does not work).

Is there a way to automatically convert the objective function of a pyomo optimization model to an expression suitable to pass to the fitness_func for pygad.GA?

EDIT:

Thank you @jsiirola for the answer (I already tried to explain in my comment to this answer why I cannot accept the answer (however, i still appreciate it!)).

The main point is: Whatever we pass to pygad.GA as fitness_func must return a "naked", non-evaluated expression, so that pygad can plug in values for the variables on its own (at least that is how I understand pygad).

So for example when I run model.obj.expr after executing the code from my MWE, I get this: param1[0]*var[0] + param1[1]*var[1] + param1[2]*var[2] + param1[3]*var[3] + param1[4]*var[4] + param1[5]*var[5] + param1[6]*var[6] + param1[7]*var[7] + param1[8]*var[8] + param1[9]*var[9]. This already looks like a "naked" non-evaluated expression I was talking about. Then the next step would be to automatically turn this expression into something like what I get from running sum((i+1)**2 * solution[i] for i in range(1, n_vars)) in the code from my MWE. And I have no clue how to do that.


Solution

  • Pyomo currently does not have a predefined solver interface to PyGAD, although it wouldn't be too hard to put something together (and Pull Requests are always welcome!).

    There are a number of ways to automatically interrogate and manipulate Pyomo expressions. Probably the easiest thing would be to declare a fitness function that takes the solution from PyGAD, load it into the Pyomo variables, and leverage Pyomo's expression evaluator to compute the objective. Because we will need to hold on to a reference to the Pyomo model, I would recommend making fitness_function a functor (callable class) as opposed to a function. Something like this would work:

    import pyomo.environ as pyo
    from pyomo.core.expr import identify_variables
    
    class FitnessFunction:
        def __init__(self, model):
            self.model = model
            # Search the model for all active objectives
            objectives = list(model.component_data_objects(pyo.Objective, active=True))
            assert(len(objectives) == 1)
            self.objective = objectives[0]
            # Determine what variables appear in the objective, and filter
            # out any fixed variables
            self.variables = list(filter(
                lambda v: not v.fixed, identify_variables(self.objective.expr)
            ))
    
        def __call__(self, ga, x, soln_idx):
            # Copy the current solution value into the Pyomo model variables
            for ga_v, pyo_v in zip(x, self.variables):
                pyo_v.value = ga_v
            # return the value of the objective function (as computed by Pyomo)
            return pyo.value(self.objective)
    
    # ...
    
    fitness_functor = FitnessFunction(model)
    
    ga_instance = pygad.GA(
        # ...
        fitness_func=fitness_functor.__call__,
        # ...
    )
    

    That said, this is not a complete solution, and is not quite a "general" PyGAD interface. In particular, this is missing:


    EDIT: there is a limitation / bug in the current PyGAD release where they use a very brittle approach for inspecting the arguments of the provided fitness function. As a result, they can ONLY accept basic Python functions / methods and not callable objects. This answer has been updated to pass the functor's __call__ method to PyGAD