Basic simplicial complex usage

[1]:
import random

import xgi

Creating a Simplicial Complex

Simplicial complexes can be created directly using the SimplicialComplex class. It inherits a number of properties from the Hypergraph one, but it has also some significant differences due to the closure property required for valid simplicial complexes.

Let us illustrate these differences by comparing directly the construction of a simplicial complex and of a hypergraph containing the same hyperedges/simplices. We begin by creating a simplicial complex SC and a hypergraph H.

[2]:
SC = xgi.SimplicialComplex()
H = xgi.Hypergraph()

We add to both of them 5 nodes

[3]:
SC.add_nodes_from(range(5))
H.add_nodes_from(range(5))
[4]:
SC.nodes, H.nodes
[4]:
(NodeView((0, 1, 2, 3, 4)), NodeView((0, 1, 2, 3, 4)))

We then add the simplex [0,1,2] to the simplicial complex and the same (as hyperedge) to the hypergraph

[5]:
SC.add_simplex([0, 1, 2])
H.add_edge([0, 1, 2])

Note that if we try to use add_edge on the simplicial complex, it will not work. This is done in order to prevent the misuse of hyperedge addition that would not protect the closure property of simplicial complexes.

[6]:
# SC.add_edge([0,1,2])      # try uncommenting and executing this cell

Now, what is this closure property we have mentioned?

By definition, a simplicial complex \(K\) is valid if for any simplex \(\sigma \in K\), all faces \(\omega \subset \sigma\) are also \(\omega \in K\). This implies that we must make sure that each face is properly added whenever we add a new simplex. The function SC.add_simplex and SC.add_simplices_from already take care of this directly. This does not happen instead when adding hyperedges to hypergraphs.

We can check this easily by asking how many edges are present in the simplicial complex versus the hypergraph. Previously, we only added the simplex/hyperedge [0,1,2,3]. The closure requires also the addition of the three edges ([0,1], [0,2], [1,2]), so there should be 4 simplices in SC and only one in H. Correctly, we find:

[7]:
SC.edges
[7]:
EdgeView((0, 1, 2, 3))
[8]:
H.edges
[8]:
EdgeView((0,))

We can further check that this is correct by checking how many hyperedges of a certain dimension (“order”) are present in the simplicial complex. We can do this using the order key as follows:

[9]:
for dim in [0, 1, 2]:
    print("# edges in H at order", dim, H.edges.filterby("order", dim))
    print("# edges in SC at order", dim, SC.edges.filterby("order", dim), "\n")
# edges in H at order 0 []
# edges in SC at order 0 []

# edges in H at order 1 []
# edges in SC at order 1 [1, 2, 3]

# edges in H at order 2 [0]
# edges in SC at order 2 [0]

Finally, we can directly access the simplex composition by using the members function of edges.

[10]:
for dim in [0, 1, 2]:
    edge_sel = list(SC.edges.filterby("order", dim))
    print(
        "Composition of simplices at order",
        dim,
        [SC.edges.members(x) for x in edge_sel],
        "\n",
    )
Composition of simplices at order 0 []

Composition of simplices at order 1 [frozenset({0, 1}), frozenset({0, 2}), frozenset({1, 2})]

Composition of simplices at order 2 [frozenset({0, 1, 2})]

As opposed to what happens in the corresponding hypergraph

[11]:
for dim in [0, 1, 2]:
    edge_sel = list(H.edges.filterby("order", dim))
    print(
        "Composition of edges at order",
        dim,
        [H.edges.members(x) for x in edge_sel],
        "\n",
    )
Composition of edges at order 0 []

Composition of edges at order 1 []

Composition of edges at order 2 [{0, 1, 2}]

Loading from a list of facets

We can also provide the SimplicialComplex class with a list of facets –maximal faces, that is, simplices that are not contained in any other simplices–. Note that if the list contains a simplex that it’s not a facet, this will not throw an error but the simplex will simply become part of the closure of a larger one in the list.

[12]:
facets = [[0, 1, 2], [0, 1, 3, 4]]
SC1 = xgi.SimplicialComplex(facets)
[13]:
SC1.edges.members()
[13]:
[frozenset({0, 1, 2}),
 frozenset({0, 1, 3, 4}),
 frozenset({0, 1}),
 frozenset({1, 2}),
 frozenset({0, 1, 4}),
 frozenset({0, 4}),
 frozenset({3, 4}),
 frozenset({0, 3, 4}),
 frozenset({0, 1, 3}),
 frozenset({0, 3}),
 frozenset({1, 3, 4}),
 frozenset({1, 4}),
 frozenset({0, 2}),
 frozenset({1, 3})]
[14]:
n = 20
m = 40

min_edge_size = 2
max_edge_size = 4

hyperedge_list = [
    random.sample(range(n), random.choice(range(min_edge_size, max_edge_size + 1)))
    for i in range(m)
]
SC_big = xgi.SimplicialComplex(hyperedge_list)
str(SC_big)
[14]:
'Unnamed SimplicialComplex with 20 nodes and 188 simplices'

Loading from a pandas DataFrame

[15]:
df = xgi.to_bipartite_pandas_dataframe(xgi.load_xgi_data("email-enron"))

When a user gives a Pandas dataframe, the system automatically imports the first two columns as lists of node and edge indices specifying a bipartite edge list. Below we compare the results of importing the dataframe as a hypergraph, in which all edges are retained just as they are in the dataframe, and as a simplicial complex, in which we cannot have repeated simplices.

[16]:
H = xgi.Hypergraph(df[:300])
str(H), H.edges.members()[:30]
[16]:
('Unnamed Hypergraph with 2 nodes and 285 hyperedges',
 [{'4'},
  {'4'},
  {'1', '4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'4'},
  {'1', '4'},
  {'4'},
  {'1', '4'},
  {'1', '4'},
  {'4'}])
[17]:
F = xgi.SimplicialComplex(df[:300])
print(f"The hypergraph has {F.num_nodes} nodes and {F.num_edges} edges")
F.edges.members()[:30]
The hypergraph has 2 nodes and 3 edges
[17]:
[frozenset({'4'}), frozenset({'1', '4'}), frozenset({'1'})]

In many cases we start from a list of facets that can include very large simplices. This however can become quickly very cumbersome because the number of subfaces scales factorially with the order of the facet. Moreover, even when we have large facets, we might be interested in quantities that are defined only on simplices of smaller dimension. One way to approach this is by reducing the maximum size of the simplices that are added to theSimplicialComplex constructor. Alternatively, it is possible to provide to the add_simplices_from function a maximum order. In that case, any simplex with order larger than max_order will be substituted with its faces of order max_order (the closure property is automatically enforced by the function).

[18]:
X = xgi.SimplicialComplex()
X.add_simplices_from(hyperedge_list, max_order=4)
str(X)
[18]:
'Unnamed SimplicialComplex with 20 nodes and 188 simplices'

and we can also check that this is the case explicitly closing the simplicial complex and checking that the size does not change.

[19]:
X.close()
str(X)
[19]:
'Unnamed SimplicialComplex with 20 nodes and 188 simplices'