Generate all digraphs of a given size up to isomorphism

Viewed 534

I am trying to generate all directed graphs with a given number of nodes up to graph isomorphism so that I can feed them into another Python program. Here is a naive reference implementation using NetworkX, I would like to speed it up:

from itertools import combinations, product
import networkx as nx

def generate_digraphs(n):
  graphs_so_far = list()
  nodes = list(range(n))
  possible_edges = [(i, j) for i, j in product(nodes, nodes) if i != j]
  for edge_mask in product([True, False], repeat=len(possible_edges)):
    edges = [edge for include, edge in zip(edge_mask, possible_edges) if include]
    g = nx.DiGraph()
    g.add_nodes_from(nodes)
    g.add_edges_from(edges)
    if not any(nx.is_isomorphic(g_before, g) for g_before in graphs_so_far):
      graphs_so_far.append(g)
  return graphs_so_far

assert len(generate_digraphs(1)) == 1
assert len(generate_digraphs(2)) == 3
assert len(generate_digraphs(3)) == 16

The number of such graphs seems to grow pretty quickly and is given by this OEIS sequence. I am looking for a solution that is able to generate all graphs up to 7 nodes (about a billion graphs in total) in a reasonable amount of time.

Representing a graph as a NetworkX object is not very important; for example, representing a graph with an adjacency list or using a different library is good with me.

3 Answers

There’s a useful idea that I learned from Brendan McKay’s paper “Isomorph-free exhaustive generation” (though I believe that it predates that paper).

The idea is that we can organize the isomorphism classes into a tree, where the singleton class with the empty graph is the root, and each class with graphs having n > 0 nodes has a parent class with graphs having n − 1 nodes. To enumerate the isomorphism classes of graphs with n > 0 nodes, enumerate the isomorphism classes of graphs with n − 1 nodes, and for each such class, extend its representatives in all possible ways to n nodes and filter out the ones that aren’t actually children.

The Python code below implements this idea with a rudimentary but nontrivial graph isomorphism subroutine. It takes a few minutes for n = 6 and (estimating here) on the order of a few days for n = 7. For extra speed, port it to C++ and maybe find better algorithms for handling the permutation groups (maybe in TAoCP, though most of the graphs have no symmetry, so it’s not clear how big the benefit would be).

import cProfile
import collections
import itertools
import random


# Returns labels approximating the orbits of graph. Two nodes in the same orbit
# have the same label, but two nodes in different orbits don't necessarily have
# different labels.
def invariant_labels(graph, n):
    labels = [1] * n
    for r in range(2):
        incoming = [0] * n
        outgoing = [0] * n
        for i, j in graph:
            incoming[j] += labels[i]
            outgoing[i] += labels[j]
        for i in range(n):
            labels[i] = hash((incoming[i], outgoing[i]))
    return labels


# Returns the inverse of perm.
def inverse_permutation(perm):
    n = len(perm)
    inverse = [None] * n
    for i in range(n):
        inverse[perm[i]] = i
    return inverse


# Returns the permutation that sorts by label.
def label_sorting_permutation(labels):
    n = len(labels)
    return inverse_permutation(sorted(range(n), key=lambda i: labels[i]))


# Returns the graph where node i becomes perm[i] .
def permuted_graph(perm, graph):
    perm_graph = [(perm[i], perm[j]) for (i, j) in graph]
    perm_graph.sort()
    return perm_graph


# Yields each permutation generated by swaps of two consecutive nodes with the
# same label.
def label_stabilizer(labels):
    n = len(labels)
    factors = (
        itertools.permutations(block)
        for (_, block) in itertools.groupby(range(n), key=lambda i: labels[i])
    )
    for subperms in itertools.product(*factors):
        yield [i for subperm in subperms for i in subperm]


# Returns the canonical labeled graph isomorphic to graph.
def canonical_graph(graph, n):
    labels = invariant_labels(graph, n)
    sorting_perm = label_sorting_permutation(labels)
    graph = permuted_graph(sorting_perm, graph)
    labels.sort()
    return max(
        (permuted_graph(perm, graph), perm[sorting_perm[n - 1]])
        for perm in label_stabilizer(labels)
    )


# Returns the list of permutations that stabilize graph.
def graph_stabilizer(graph, n):
    return [
        perm
        for perm in label_stabilizer(invariant_labels(graph, n))
        if permuted_graph(perm, graph) == graph
    ]


# Yields the subsets of range(n) .
def power_set(n):
    for r in range(n + 1):
        for s in itertools.combinations(range(n), r):
            yield list(s)


# Returns the set where i becomes perm[i] .
def permuted_set(perm, s):
    perm_s = [perm[i] for i in s]
    perm_s.sort()
    return perm_s


# If s is canonical, returns the list of permutations in group that stabilize s.
# Otherwise, returns None.
def set_stabilizer(s, group):
    stabilizer = []
    for perm in group:
        perm_s = permuted_set(perm, s)
        if perm_s < s:
            return None
        if perm_s == s:
            stabilizer.append(perm)
    return stabilizer


# Yields one representative of each isomorphism class.
def enumerate_graphs(n):
    assert 0 <= n
    if 0 == n:
        yield []
        return
    for subgraph in enumerate_graphs(n - 1):
        sub_stab = graph_stabilizer(subgraph, n - 1)
        for incoming in power_set(n - 1):
            in_stab = set_stabilizer(incoming, sub_stab)
            if not in_stab:
                continue
            for outgoing in power_set(n - 1):
                out_stab = set_stabilizer(outgoing, in_stab)
                if not out_stab:
                    continue
                graph, i_star = canonical_graph(
                    subgraph
                    + [(i, n - 1) for i in incoming]
                    + [(n - 1, j) for j in outgoing],
                    n,
                )
                if i_star == n - 1:
                    yield graph


def test():
    print(sum(1 for graph in enumerate_graphs(5)))


cProfile.run("test()")

98-99% of computation time is used for the isomorphism tests, so the name of the game is to reduce the number of necessary tests. Here, I create the graphs in batches such that graphs have to be tested for isomorphisms only within a batch.

In the first variant (version 2 below), all graphs within a batch have the same number of edges. This leads to appreaciable but moderate improvements in running time (2.5 times faster for graphs of size 4, with larger gains in speed for larger graphs).

In the second variant (version 3 below), all graphs within a batch have the same out-degree sequence. This leads to substantial improvements in running time (35 times faster for graphs of size 4, with larger gains in speed for larger graphs).

In the third variant (version 4 below), all graphs within a batch have the same out-degree sequence. Additionally, within a batch all graphs are sorted by in-degree sequence. This leads to modest improvements in speed compared to version 3 (1.3 times faster for graphs of size 4; 2.1 times faster for graphs of size 5).

enter image description here

#!/usr/bin/env python
"""
Efficient motif generation.
"""

import numpy as np
import matplotlib.pyplot as plt
import networkx as nx

from timeit import timeit
from itertools import combinations, product, chain, combinations_with_replacement


# for profiling with kernprof/line_profiler
try:
    profile
except NameError:
    profile = lambda x: x


@profile
def version_1(n):
    """Original implementation by @hilberts_drinking_problem"""
    graphs_so_far = list()
    nodes = list(range(n))
    possible_edges = [(i, j) for i, j in product(nodes, nodes) if i != j]
    for edge_mask in product([True, False], repeat=len(possible_edges)):
        edges = [edge for include, edge in zip(edge_mask, possible_edges) if include]
        g = nx.DiGraph()
        g.add_nodes_from(nodes)
        g.add_edges_from(edges)
        if not any(nx.is_isomorphic(g_before, g) for g_before in graphs_so_far):
            graphs_so_far.append(g)
    return graphs_so_far


@profile
def version_2(n):
    """Creates graphs in batches, where each batch contains graphs with
    the same number of edges. Only graphs within a batch have to be tested
    for isomorphisms."""
    graphs_so_far = list()
    nodes = list(range(n))
    possible_edges = [(i, j) for i, j in product(nodes, nodes) if i != j]
    for ii in range(len(possible_edges)+1):
        tmp = []
        for edges in combinations(possible_edges, ii):
            g = nx.from_edgelist(edges, create_using=nx.DiGraph)
            if not any(nx.is_isomorphic(g_before, g) for g_before in tmp):
                tmp.append(g)
        graphs_so_far.extend(tmp)
    return graphs_so_far


@profile
def version_3(n):
    """Creates graphs in batches, where each batch contains graphs with
    the same out-degree sequence. Only graphs within a batch have to be tested
    for isomorphisms."""
    graphs_so_far = list()
    outdegree_sequences_so_far = list()
    for outdegree_sequence in product(*[range(n) for _ in range(n)]):
        # skip degree sequences which we have already seen as the resulting graphs will be isomorphic
        if sorted(outdegree_sequence) not in outdegree_sequences_so_far:
            tmp = []
            for edges in generate_graphs(outdegree_sequence):
                g = nx.from_edgelist(edges, create_using=nx.DiGraph)
                if not any(nx.is_isomorphic(g_before, g) for g_before in tmp):
                    tmp.append(g)
            graphs_so_far.extend(tmp)
            outdegree_sequences_so_far.append(sorted(outdegree_sequence))
    return graphs_so_far


def generate_graphs(outdegree_sequence):
    """Generates all directed graphs with a given out-degree sequence."""
    for edges in product(*[generate_edges(node, degree, len(outdegree_sequence)) \
                           for node, degree in enumerate(outdegree_sequence)]):
        yield(list(chain(*edges)))


def generate_edges(node, outdegree, total_nodes):
    """Generates all edges for a given node with a given out-degree and a given graph size."""
    for targets in combinations(set(range(total_nodes)) - {node}, outdegree):
        yield([(node, target) for target in targets])


@profile
def version_4(n):
    """Creates graphs in batches, where each batch contains graphs with
    the same out-degree sequence.  Within a batch, graphs are sorted
    by in-degree sequence, such that only graphs with the same
    in-degree sequence have to be tested for isomorphism.
    """
    graphs_so_far = list()
    for outdegree_sequence in combinations_with_replacement(range(n), n):
        tmp = dict()
        for edges in generate_graphs(outdegree_sequence):
            g = nx.from_edgelist(edges, create_using=nx.DiGraph)
            indegree_sequence = tuple(sorted(degree for _, degree in g.in_degree()))
            if indegree_sequence in tmp:
                if not any(nx.is_isomorphic(g_before, g) for g_before in tmp[indegree_sequence]):
                    tmp[indegree_sequence].append(g)
            else:
                tmp[indegree_sequence] = [g]
        for graphs in tmp.values():
            graphs_so_far.extend(graphs)
    return graphs_so_far


if __name__ == '__main__':

    order = range(1, 5)
    t1 = [timeit(lambda : version_1(n), number=3) for n in order]
    t2 = [timeit(lambda : version_2(n), number=3) for n in order]
    t3 = [timeit(lambda : version_3(n), number=3) for n in order]
    t4 = [timeit(lambda : version_4(n), number=3) for n in order]

    fig, ax = plt.subplots()
    for ii, t in enumerate([t1, t2, t3, t4]):
        ax.plot(t, label=f"Version no. {ii+1}")
    ax.set_yscale('log')
    ax.set_ylabel('Execution time [s]')
    ax.set_xlabel('Graph order')
    ax.legend()
    plt.show()

Instead of using nx.is_isomorphic to compare two graphs G1 and G2, you could also generate all graphs that are isomorphic to G1 and check if the G2 is in this set. At first, this sounds more cumbersome, but it allows you to not just check if G2 is isomorphic to G1, but also if any graph is isomorphic to G1, whereas nx.is_isomorphic always starts from scratch when comparing two graphs.

To make things easier each graph is just stored as a list of edges. Two graphs are the same (not isomorphic) if the set of all edges is the same. Always making sure the list of edges is a sorted tuple makes it so that == tests for exactly that equality and makes the edge lists hashable.

import itertools


def all_digraphs(n):
    possible_edges = [
        (i, j) for i, j in itertools.product(range(n), repeat=2) if i != j
    ]
    for edge_mask in itertools.product([True, False], repeat=len(possible_edges)):
        # The result is already sorted
        yield tuple(edge for include, edge in zip(edge_mask, possible_edges) if include)


def unique_digraphs(n):
    already_seen = set()
    for graph in all_digraphs(n):
        if graph not in already_seen:
            yield graph
            already_seen |= {
                tuple(sorted((perm[i], perm[j]) for i, j in graph))
                for perm in itertools.permutations(range(n))
            }

Compared to the variants from the previous solution this gives the following timings on my machine:

Timings of the different algorithms

This all looks quite promising, but already for 6 nodes my 16GiB of memory is not enough and the Python process is terminated by the operating system. I'm sure you can combine this code with generating the graphs in batches for each outdegree_sequence as detailed in the previous answer. This would allow one to empty already_seen after each batch and reduce the memory consumption drastically.

Related