pyomo icon indicating copy to clipboard operation
pyomo copied to clipboard

[PEP] NetworkX Graph Representation of Pyomo Model

Open michaelbynum opened this issue 3 years ago • 7 comments

Summary

There are many applications in which it is useful to generate a graph representation of a pyomo model using NetworkX. It would be nice to have a single common function/module in Pyomo with this functionality that can be utilized for multiple applications. However, the desired API is not clear. The purpose of this PEP is to discuss the API.

The problem

The problem is that nodes in a NetworkX graph must be hashable. This motivates two potential solutions:

  1. Create hashable wrappers for Pyomo components and use them as nodes in the NetworkX graph.
  2. Use a hashable object (probably an int) as nodes and provide some sort of mapping from nodes to Pyomo components.

Solution 1: Hashable Wrappers

What it looks like

class HashableWrapper(object):
    def __init__(self, comp):
        self.comp = comp

    def __eq__(self, other):
        if type(other) is _CompNode:
            return self.comp is other.comp
        return False

    def __hash__(self):
        return hash(id(self.comp))


def graph_from_pyomo(m: _BlockData,
                     include_objective: bool = True,
                     active: bool = True) -> nx.Graph:
    graph = nx.Graph()

    for v in ComponentSet(m.component_data_objects(pe.Var, descend_into=True)):
        graph.add_node(HashableWrapper(v))

    for con in OrderedSet(m.component_data_objects(pe.Constraint, descend_into=True, active=active)):
        graph.add_node(HashableWrapper(con)) # This does not need the wrapper, but consistency is nice
        for v in identify_variables(con.body, include_fixed=True):
            graph.add_edge(HashableWrapper(con), HashableWrapper(v))

    if include_objective:
        for obj in ComponentSet(m.component_data_objects(pe.Objective, descend_into=True, active=active)):
            graph.add_node(HashableWrapper(obj))
            for v in identify_variables(obj.expr, include_fixed=True):
                graph.add_edge(HashableWrapper(obj), HashableWrapper(v))

    return graph

Example Usage

g = graph_from_pyomo(m)
for n1, n2 in graph.edges():
    n1.comp.pprint()

for v in m.component_data_objects(pe.Var):
    g.neighbors(HashableWrapper(v))

Pros

  • Easy to use
  • Only one object to worry about

Cons

  • Needs a new object for every Pyomo component

Solution 2: Provide a mapping from nodes to Pyomo components

What it looks like

def graph_from_pyomo(m: _BlockData, include_objective: bool = True, active: bool = True) -> nx.Graph:
    graph = nx.Graph()

    ndx = 0
    comp_map = dict()
    rev_comp_map = ComponentMap()

    for v in ComponentSet(m.component_data_objects(pe.Var, descend_into=True)):
        graph.add_node(ndx)
        comp_map[ndx] = v
        rev_comp_map[v] = ndx
        ndx += 1

    for con in OrderedSet(m.component_data_objects(pe.Constraint, descend_into=True, active=active)):
        graph.add_node(ndx)
        comp_map[ndx] = con
        rev_comp_map[con] = ndx
        ndx += 1
        for v in identify_variables(con.body, include_fixed=True):
            graph.add_edge(rev_comp_map[con], rev_comp_map[v])

    if include_objective:
        for obj in ComponentSet(m.component_data_objects(pe.Objective, descend_into=True, active=active)):
            graph.add_node(ndx)
            comp_map[ndx] = obj
            rev_comp_map[obj] = ndx
            ndx += 1
            for v in identify_variables(obj.expr, include_fixed=True):
                graph.add_edge(rev_comp_map[obj], rev_comp_map[v])

    return graph, comp_map, rev_comp_map

Example Usage

g, cm, rcm = graph_from_pyomo(m)
for n1, n2 in graph.edges():
    cm[n1].pprint()

for v in m.component_data_objects(pe.Var):
    g.neighbors(rcm[v])

Pros

  • Very simple (I like simple)
  • Lightweight solution

Cons

  • Working with the resulting graph is slightly more cumbersome

michaelbynum avatar Jan 26 '22 15:01 michaelbynum

Either one seems like a good idea. My question (which really is a question) is: why is this a pep? Won't this be a utility that users choose to employ? In what ways would it impact anything for users who choose not to employ it?

DLWoodruff avatar Jan 26 '22 22:01 DLWoodruff

It is more of a design discussion than a PEP. I probably used the wrong label. It would not impact users who do not choose to employ it.

michaelbynum avatar Jan 26 '22 22:01 michaelbynum

Thank you Michael for putting this together. I think it is important to have this discussion based on the fact that both CommunityDetection and IncidenceAnalysis contrib packages are generating graphs from Pyomo models. My vote between the two alternatives that you proposed would be for the second one, given the simplicity of the code and the fact that one can further work on the generated graph with the maps as a blueprint.

bernalde avatar Jan 29 '22 02:01 bernalde

I like the second option too. A related question: Should we add all variables to the graph, or only variables that participate in (active) constraints/objectives?

Robbybp avatar Feb 05 '22 02:02 Robbybp

I agree with the second option.

@Robbybp I think that should be an option, but I'm not sure what the default should be.

michaelbynum avatar Feb 07 '22 14:02 michaelbynum

Depends on if you want the model that will be seen by the solver, or the model as written, I guess. I usually want the model that will be seen by the solver, so I went with the variables-in-active-constraints approach for incidence analysis.

Robbybp avatar Feb 07 '22 15:02 Robbybp

I think there should be an option that defaults for only active components (as sometimes one can have a lot of inactive things in the model). Alright, I think we have an agreement, so what should be the way forward? Have a single graph_from_pyomo(m) function that we can call from the other packages?

bernalde avatar Feb 07 '22 16:02 bernalde