diff --git a/docs/source/api/algorithms.rst b/docs/source/api/algorithms.rst index 272894060..0d7376111 100644 --- a/docs/source/api/algorithms.rst +++ b/docs/source/api/algorithms.rst @@ -10,4 +10,5 @@ algorithms package ~xgi.algorithms.assortativity ~xgi.algorithms.centrality ~xgi.algorithms.clustering - ~xgi.algorithms.connected \ No newline at end of file + ~xgi.algorithms.connected + ~xgi.algorithms.shortest_path \ No newline at end of file diff --git a/docs/source/api/classes.rst b/docs/source/api/classes.rst index 7cc1293af..7fcb2092d 100644 --- a/docs/source/api/classes.rst +++ b/docs/source/api/classes.rst @@ -8,7 +8,9 @@ classes package :toctree: classes ~xgi.classes.hypergraph + ~xgi.classes.dihypergraph ~xgi.classes.simplicialcomplex ~xgi.classes.reportviews + ~xgi.classes.direportviews ~xgi.classes.hypergraphviews ~xgi.classes.function diff --git a/docs/source/api/classes/xgi.classes.dihypergraph.DiHypergraph.rst b/docs/source/api/classes/xgi.classes.dihypergraph.DiHypergraph.rst new file mode 100644 index 000000000..beb6a8aad --- /dev/null +++ b/docs/source/api/classes/xgi.classes.dihypergraph.DiHypergraph.rst @@ -0,0 +1,35 @@ +xgi.classes.dihypergraph.DiHypergraph +===================================== + +.. currentmodule:: xgi.classes.dihypergraph + +.. autoclass:: DiHypergraph + :show-inheritance: + :members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~DiHypergraph.edges + ~DiHypergraph.nodes + ~DiHypergraph.num_edges + ~DiHypergraph.num_nodes + + + .. rubric:: Methods that modify the structure + + .. autosummary:: + :nosignatures: + + ~DiHypergraph.add_node + ~DiHypergraph.add_edge + ~DiHypergraph.add_nodes_from + ~DiHypergraph.add_edges_from + ~DiHypergraph.remove_node + ~DiHypergraph.remove_edge + ~DiHypergraph.remove_nodes_from + ~DiHypergraph.remove_edges_from + ~DiHypergraph.clear + ~DiHypergraph.copy \ No newline at end of file diff --git a/docs/source/api/classes/xgi.classes.dihypergraph.rst b/docs/source/api/classes/xgi.classes.dihypergraph.rst new file mode 100644 index 000000000..b7c28d28c --- /dev/null +++ b/docs/source/api/classes/xgi.classes.dihypergraph.rst @@ -0,0 +1,14 @@ +xgi.classes.dihypergraph +======================== + +.. currentmodule:: xgi.classes.dihypergraph + +.. automodule:: xgi.classes.dihypergraph + + .. rubric:: Classes + + .. autosummary:: + :toctree: . + :nosignatures: + + DiHypergraph \ No newline at end of file diff --git a/docs/source/api/classes/xgi.classes.direportviews.DiEdgeView.rst b/docs/source/api/classes/xgi.classes.direportviews.DiEdgeView.rst new file mode 100644 index 000000000..ace79008a --- /dev/null +++ b/docs/source/api/classes/xgi.classes.direportviews.DiEdgeView.rst @@ -0,0 +1,28 @@ +xgi.classes.direportviews.DiEdgeView +==================================== + +.. currentmodule:: xgi.classes.direportviews + +.. autoclass:: DiEdgeView + :show-inheritance: + :members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~DiEdgeView.ids + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~DiEdgeView.members + ~DiEdgeView.dimembers + ~DiEdgeView.head + ~DiEdgeView.tail + ~DiIDView.filterby + ~DiIDView.filterby_attr \ No newline at end of file diff --git a/docs/source/api/classes/xgi.classes.direportviews.DiIDView.rst b/docs/source/api/classes/xgi.classes.direportviews.DiIDView.rst new file mode 100644 index 000000000..73616dc97 --- /dev/null +++ b/docs/source/api/classes/xgi.classes.direportviews.DiIDView.rst @@ -0,0 +1,18 @@ +xgi.classes.direportviews.DiIDView +================================== + +.. currentmodule:: xgi.classes.direportviews + +.. autoclass:: DiIDView + :show-inheritance: + :members: + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~DiIDView.from_view + ~DiIDView.filterby + ~DiIDView.filterby_attr diff --git a/docs/source/api/classes/xgi.classes.direportviews.DiNodeView.rst b/docs/source/api/classes/xgi.classes.direportviews.DiNodeView.rst new file mode 100644 index 000000000..5afd5af37 --- /dev/null +++ b/docs/source/api/classes/xgi.classes.direportviews.DiNodeView.rst @@ -0,0 +1,26 @@ +xgi.classes.direportviews.DiNodeView +==================================== + +.. currentmodule:: xgi.classes.direportviews + +.. autoclass:: DiNodeView + :show-inheritance: + :members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~DiNodeView.ids + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~DiNodeView.memberships + ~DiNodeView.dimemberships + ~DiIDView.filterby + ~DiIDView.filterby_attr \ No newline at end of file diff --git a/docs/source/api/classes/xgi.classes.direportviews.rst b/docs/source/api/classes/xgi.classes.direportviews.rst new file mode 100644 index 000000000..d64d03799 --- /dev/null +++ b/docs/source/api/classes/xgi.classes.direportviews.rst @@ -0,0 +1,16 @@ +xgi.classes.direportviews +========================= + +.. currentmodule:: xgi.classes.direportviews + +.. automodule:: xgi.classes.direportviews + + .. rubric:: Classes + + .. autosummary:: + :toctree: . + :nosignatures: + + DiIDView + DiNodeView + DiEdgeView \ No newline at end of file diff --git a/docs/source/api/classes/xgi.classes.reportviews.EdgeView.rst b/docs/source/api/classes/xgi.classes.reportviews.EdgeView.rst index 982ccdae3..6eeabbfe8 100644 --- a/docs/source/api/classes/xgi.classes.reportviews.EdgeView.rst +++ b/docs/source/api/classes/xgi.classes.reportviews.EdgeView.rst @@ -12,7 +12,7 @@ xgi.classes.reportviews.EdgeView .. autosummary:: - ~NodeView.ids + ~EdgeView.ids .. rubric:: Methods diff --git a/docs/source/api/stats.rst b/docs/source/api/stats.rst index 39111e178..cc5bd594e 100644 --- a/docs/source/api/stats.rst +++ b/docs/source/api/stats.rst @@ -11,6 +11,8 @@ stats package ~xgi.stats.nodestats ~xgi.stats.edgestats + ~xgi.stats.dinodestats + ~xgi.stats.diedgestats .. rubric:: Classes @@ -21,8 +23,12 @@ stats package ~xgi.stats.NodeStat ~xgi.stats.EdgeStat + ~xgi.stats.DiNodeStat + ~xgi.stats.DiEdgeStat ~xgi.stats.MultiNodeStat ~xgi.stats.MultiEdgeStat + ~xgi.stats.MultiDiNodeStat + ~xgi.stats.MultiDiEdgeStat .. rubric:: Decorators @@ -33,3 +39,5 @@ stats package ~xgi.stats.nodestat_func ~xgi.stats.edgestat_func + ~xgi.stats.dinodestat_func + ~xgi.stats.diedgestat_func diff --git a/docs/source/api/stats/xgi.stats.DiEdgeStat.rst b/docs/source/api/stats/xgi.stats.DiEdgeStat.rst new file mode 100644 index 000000000..fc2808218 --- /dev/null +++ b/docs/source/api/stats/xgi.stats.DiEdgeStat.rst @@ -0,0 +1,34 @@ +xgi.stats.DiEdgeStat +==================== + +.. currentmodule:: xgi.stats + +.. autoclass:: DiEdgeStat + :show-inheritance: + :members: + :inherited-members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~DiEdgeStat.name + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~DiEdgeStat.asdict + ~DiEdgeStat.aslist + ~DiEdgeStat.asnumpy + ~DiEdgeStat.aspandas + ~DiEdgeStat.max + ~DiEdgeStat.mean + ~DiEdgeStat.median + ~DiEdgeStat.min + ~DiEdgeStat.std + ~DiEdgeStat.var + ~DiEdgeStat.moment \ No newline at end of file diff --git a/docs/source/api/stats/xgi.stats.DiNodeStat.rst b/docs/source/api/stats/xgi.stats.DiNodeStat.rst new file mode 100644 index 000000000..878875573 --- /dev/null +++ b/docs/source/api/stats/xgi.stats.DiNodeStat.rst @@ -0,0 +1,33 @@ +xgi.stats.DiNodeStat +==================== + +.. currentmodule:: xgi.stats + +.. autoclass:: DiNodeStat + :show-inheritance: + :members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~DiNodeStat.name + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~DiNodeStat.asdict + ~DiNodeStat.aslist + ~DiNodeStat.asnumpy + ~DiNodeStat.aspandas + ~DiNodeStat.max + ~DiNodeStat.mean + ~DiNodeStat.median + ~DiNodeStat.min + ~DiNodeStat.std + ~DiNodeStat.var + ~DiNodeStat.moment diff --git a/docs/source/api/stats/xgi.stats.MultiDiEdgeStat.rst b/docs/source/api/stats/xgi.stats.MultiDiEdgeStat.rst new file mode 100644 index 000000000..49d2a1f42 --- /dev/null +++ b/docs/source/api/stats/xgi.stats.MultiDiEdgeStat.rst @@ -0,0 +1,27 @@ +xgi.stats.MultiDiEdgeStat +========================= + +.. currentmodule:: xgi.stats + +.. autoclass:: MultiDiEdgeStat + :show-inheritance: + :members: + :inherited-members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~MultiDiEdgeStat.name + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~MultiDiEdgeStat.asdict + ~MultiDiEdgeStat.aslist + ~MultiDiEdgeStat.asnumpy + ~MultiDiEdgeStat.aspandas diff --git a/docs/source/api/stats/xgi.stats.MultiDiNodeStat.rst b/docs/source/api/stats/xgi.stats.MultiDiNodeStat.rst new file mode 100644 index 000000000..f0f6c70ff --- /dev/null +++ b/docs/source/api/stats/xgi.stats.MultiDiNodeStat.rst @@ -0,0 +1,27 @@ +xgi.stats.MultiDiNodeStat +========================= + +.. currentmodule:: xgi.stats + +.. autoclass:: MultiDiNodeStat + :show-inheritance: + :members: + :inherited-members: + + + .. rubric:: Attributes + + .. autosummary:: + + ~MultiDiNodeStat.name + + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~MultiDiNodeStat.asdict + ~MultiDiNodeStat.aslist + ~MultiDiNodeStat.asnumpy + ~MultiDiNodeStat.aspandas \ No newline at end of file diff --git a/docs/source/api/stats/xgi.stats.MultiEdgeStat.rst b/docs/source/api/stats/xgi.stats.MultiEdgeStat.rst index db51b7f5b..f2422a4ea 100644 --- a/docs/source/api/stats/xgi.stats.MultiEdgeStat.rst +++ b/docs/source/api/stats/xgi.stats.MultiEdgeStat.rst @@ -21,7 +21,7 @@ .. autosummary:: :nosignatures: - ~MultiNodeStat.asdict - ~MultiNodeStat.aslist - ~MultiNodeStat.asnumpy - ~MultiNodeStat.aspandas + ~MultiEdgeStat.asdict + ~MultiEdgeStat.aslist + ~MultiEdgeStat.asnumpy + ~MultiEdgeStat.aspandas diff --git a/docs/source/api/stats/xgi.stats.diedgestat_func.rst b/docs/source/api/stats/xgi.stats.diedgestat_func.rst new file mode 100644 index 000000000..912767cd2 --- /dev/null +++ b/docs/source/api/stats/xgi.stats.diedgestat_func.rst @@ -0,0 +1,6 @@ +xgi.stats.diedgestat\_func +========================== + +.. currentmodule:: xgi.stats + +.. autofunction:: diedgestat_func \ No newline at end of file diff --git a/docs/source/api/stats/xgi.stats.diedgestats.rst b/docs/source/api/stats/xgi.stats.diedgestats.rst new file mode 100644 index 000000000..ec3a1cce0 --- /dev/null +++ b/docs/source/api/stats/xgi.stats.diedgestats.rst @@ -0,0 +1,16 @@ +xgi.stats.diedgestats +===================== + +.. currentmodule:: xgi.stats.diedgestats + +.. automodule:: xgi.stats.diedgestats + + .. rubric:: Functions + + .. autofunction:: attrs + .. autofunction:: order + .. autofunction:: size + .. autofunction:: head_order + .. autofunction:: head_size + .. autofunction:: tail_order + .. autofunction:: tail_size \ No newline at end of file diff --git a/docs/source/api/stats/xgi.stats.dinodestat_func.rst b/docs/source/api/stats/xgi.stats.dinodestat_func.rst new file mode 100644 index 000000000..52fdfaf53 --- /dev/null +++ b/docs/source/api/stats/xgi.stats.dinodestat_func.rst @@ -0,0 +1,6 @@ +xgi.stats.dinodestat\_func +========================== + +.. currentmodule:: xgi.stats + +.. autofunction:: dinodestat_func \ No newline at end of file diff --git a/docs/source/api/stats/xgi.stats.dinodestats.rst b/docs/source/api/stats/xgi.stats.dinodestats.rst new file mode 100644 index 000000000..6f7afff0c --- /dev/null +++ b/docs/source/api/stats/xgi.stats.dinodestats.rst @@ -0,0 +1,13 @@ +xgi.stats.dinodestats +===================== + +.. currentmodule:: xgi.stats.dinodestats + +.. automodule:: xgi.stats.dinodestats + + .. rubric:: Functions + + .. autofunction:: attrs + .. autofunction:: degree + .. autofunction:: in_degree + .. autofunction:: out_degree \ No newline at end of file diff --git a/docs/source/api/utils/xgi.utils.utilities.IDDict.rst b/docs/source/api/utils/xgi.utils.utilities.IDDict.rst index 9d4abc07f..7fb45072a 100644 --- a/docs/source/api/utils/xgi.utils.utilities.IDDict.rst +++ b/docs/source/api/utils/xgi.utils.utilities.IDDict.rst @@ -1,14 +1,14 @@ -xgi.classes.hypergraph.IDDict -============================= +xgi.utils.utilities.IDDict +========================== -.. currentmodule:: xgi.classes.hypergraph +.. currentmodule:: xgi.utils.utilities .. autoclass:: IDDict :show-inheritance: :members: - .. rubric:: Methods .. autosummary:: - :nosignatures: \ No newline at end of file + :nosignatures: + diff --git a/tests/classes/test_dihypergraph.py b/tests/classes/test_dihypergraph.py new file mode 100644 index 000000000..8d298455f --- /dev/null +++ b/tests/classes/test_dihypergraph.py @@ -0,0 +1,491 @@ +import pickle +import tempfile + +import pytest + +import xgi +from xgi.exception import IDNotFound, XGIError +from xgi.utils import dual_dict + + +def test_constructor(diedgelist1, diedgedict1): + H_list = xgi.DiHypergraph(diedgelist1) + H_dict = xgi.DiHypergraph(diedgedict1) + H_hg = xgi.DiHypergraph(H_list) + + assert set(H_list.nodes) == set(H_dict.nodes) == set(H_hg.nodes) + assert set(H_list.edges) == set(H_dict.edges) == set(H_hg.edges) + for e in H_hg.edges: + assert ( + H_list.edges.members(e) == H_dict.edges.members(e) == H_hg.edges.members(e) + ) + + with pytest.raises(XGIError): + xgi.DiHypergraph(1) + + +def test_hypergraph_attrs(): + H = xgi.DiHypergraph() + assert H._hypergraph == {} + with pytest.raises(XGIError): + H["name"] + H = xgi.DiHypergraph(name="test") + assert H["name"] == "test" + + +def test_contains(diedgelist1): + el1 = diedgelist1 + H = xgi.DiHypergraph(el1) + unique_nodes = {node for edge in el1 for node in edge[0].union(edge[1])} + for node in unique_nodes: + assert node in H + + # test TypeError handling + assert [0] not in H + + +def test_string(): + H1 = xgi.DiHypergraph() + assert str(H1) == "Unnamed DiHypergraph with 0 nodes and 0 hyperedges" + H2 = xgi.DiHypergraph(name="test") + assert str(H2) == "DiHypergraph named test with 0 nodes and 0 hyperedges" + + +def test_len(diedgelist1): + assert len(xgi.DiHypergraph(diedgelist1)) == 8 + + +def test_add_nodes_from(attr1, attr2, attr3): + H = xgi.DiHypergraph() + H.add_nodes_from(range(3), **attr1) + assert H.nodes[0]["color"] == attr1["color"] + assert H.nodes[1]["color"] == attr1["color"] + assert H.nodes[2]["color"] == attr1["color"] + + H = xgi.DiHypergraph() + H.add_nodes_from(zip(range(3), [attr1, attr2, attr3])) + assert H.nodes[0]["color"] == attr1["color"] + assert H.nodes[1]["color"] == attr2["color"] + assert H.nodes[2]["color"] == attr3["color"] + + +def test_add_node_attr(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + assert "new_node" not in H + H.add_node("new_node", color="red") + assert "new_node" in H + assert "color" in H.nodes["new_node"] + assert H.nodes["new_node"]["color"] == "red" + + +def test_hypergraph_attr(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + with pytest.raises(XGIError): + H["color"] + H["color"] = "red" + assert H["color"] == "red" + + +def test_memberships(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + assert H.nodes.memberships(1) == {0} + assert H.nodes.memberships(2) == {0} + assert H.nodes.memberships(3) == {0} + assert H.nodes.memberships(4) == {0} + assert H.nodes.memberships(6) == {1} + assert H.nodes([1, 2, 6]).memberships() == {1: {0}, 2: {0}, 6: {1}} + with pytest.raises(IDNotFound): + H.nodes.memberships(0) + with pytest.raises(TypeError): + H.nodes.memberships(slice(1, 4)) + + +def test_dimemberships(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + assert H.nodes.dimemberships(1) == (set(), {0}) + assert H.nodes.dimemberships(2) == (set(), {0}) + assert H.nodes.dimemberships(3) == (set(), {0}) + assert H.nodes.dimemberships(4) == ({0}, set()) + assert H.nodes.dimemberships(6) == ({1}, {1}) + assert H.nodes([1, 2, 6]).dimemberships() == { + 1: (set(), {0}), + 2: (set(), {0}), + 6: ({1}, {1}), + } + with pytest.raises(IDNotFound): + H.nodes.memberships(0) + with pytest.raises(TypeError): + H.nodes.memberships(slice(1, 4)) + + +def test_add_edge_accepts_different_types(): + for edge in [([1, 2, 3], [4]), [{1, 2, 3}, {4}], (iter([1, 2, 3]), iter([4]))]: + H = xgi.DiHypergraph() + H.add_edge(edge) + assert (1 in H) and (2 in H) and (3 in H) and (4 in H) + assert 0 in H.edges + assert {1, 2, 3, 4} in H.edges.members() + assert {1, 2, 3, 4} == H.edges.members(0) + assert H.edges.members(dtype=dict) == {0: {1, 2, 3, 4}} + assert H.edges.tail(dtype=dict) == {0: {1, 2, 3}} + assert H.edges.head(dtype=dict) == {0: {4}} + + +def test_add_edge_raises_with_empty_edges(): + H = xgi.DiHypergraph() + for edge in [[], {}, iter([])]: + with pytest.raises(XGIError): + H.add_edge(edge) + + for edge in [[[], []], (set(), set())]: + with pytest.raises(XGIError): + H.add_edge(edge) + + +def test_add_edge_rejects_set(): + H = xgi.DiHypergraph() + with pytest.raises(XGIError): + H.add_edge({(1, 2), (3, 4)}) + + +def test_add_edge_handles_uid_correctly(): + H1 = xgi.DiHypergraph() + H1.add_edge(([1, 2], [3]), id=0) + H1.add_edge(([3, 4], [4, 5]), id=2) + H1.add_edge([[5, 6], [2, 3]]) + assert H1.edges.dimembers(dtype=dict) == { + 0: ({1, 2}, {3}), + 2: ({3, 4}, {4, 5}), + 3: ({5, 6}, {2, 3}), + } + + +def test_add_edge_warns_when_overwriting_edge_id(): + H2 = xgi.DiHypergraph() + H2.add_edge(([1, 2], [3])) + H2.add_edge(([3, 4], [5, 6, 7])) + with pytest.warns(Warning): + H2.add_edge(([5, 6], [8]), id=0) + assert H2._edge_out == {0: {1, 2}, 1: {3, 4}} + + +def test_add_edge_with_id(): + H = xgi.DiHypergraph() + H.add_edge(([1, 2, 3], [3, 4]), id="myedge") + assert (1 in H) and (2 in H) and (3 in H) and (4 in H) + assert "myedge" in H.edges + assert {1, 2, 3, 4} in H.edges.members() + assert {1, 2, 3, 4} == H.edges.members("myedge") + assert ({1, 2, 3}, {3, 4}) in H.edges.dimembers() + assert ({1, 2, 3}, {3, 4}) == H.edges.dimembers("myedge") + assert H.edges.members(dtype=dict) == {"myedge": {1, 2, 3, 4}} + + +def test_add_edge_with_attr(): + H = xgi.DiHypergraph() + H.add_edge(([1, 2, 3], [1, 4]), color="red", place="peru") + assert (1 in H) and (2 in H) and (3 in H) and (4 in H) + assert 0 in H.edges + assert {1, 2, 3, 4} in H.edges.members() + assert {1, 2, 3, 4} == H.edges.members(0) + assert ({1, 2, 3}, {1, 4}) in H.edges.dimembers() + assert ({1, 2, 3}, {1, 4}) == H.edges.dimembers(0) + assert H.edges.members(dtype=dict) == {0: {1, 2, 3, 4}} + assert H.edges[0] == {"color": "red", "place": "peru"} + + +def test_add_edges_from_iterable_of_members(): + edges = [({0, 1}, {2}), ({1, 2}, {4}), ({2, 3, 4}, {1})] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert H.edges.dimembers() == edges + + H1 = xgi.DiHypergraph(edges) + with pytest.raises(XGIError): + xgi.DiHypergraph(H1.edges) + + edges = { + (frozenset([0, 1]), frozenset([2])), + (frozenset([1, 2]), frozenset([4])), + (frozenset([2, 3, 4]), frozenset([1])), + } + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert H.edges.dimembers() == [(set(e[0]), set(e[1])) for e in edges] + + edges = [([0, 1], {2}), [{1, 2}, [4]], ((2, 3, 4), [1])] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert H.edges.dimembers() == [(set(e[0]), set(e[1])) for e in edges] + + +def test_add_edges_from_format2(): + edges = [(({0, 1}, {2}), 0), (({1, 2}, {4}), 1), (({2, 3, 4}, {1}), 2)] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert list(H.edges) == [e[1] for e in edges] + assert H.edges.dimembers(dtype=dict) == {e[1]: (e[0][0], e[0][1]) for e in edges} + + edges = [(({0, 1}, {2}), "a"), (({1, 2}, {4}), "b"), (({2, 3, 4}, {1}), "foo")] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert list(H.edges) == [e[1] for e in edges] + assert H.edges.dimembers(dtype=dict) == {e[1]: (e[0][0], e[0][1]) for e in edges} + + edges = [(({0, 1}, {2}), "a"), (({1, 2}, {4}), "b"), (({2, 3, 4}, {1}), 100)] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert list(H.edges) == [e[1] for e in edges] + assert H.edges.dimembers(dtype=dict) == {e[1]: (e[0][0], e[0][1]) for e in edges} + + # check counter + H.add_edge(([1, 9, 2], [10])) + assert H.edges.members(101) == {1, 2, 9, 10} + + H1 = xgi.DiHypergraph([({1, 2}, {3}), ({2, 3, 4}, {1})]) + with pytest.warns(Warning): + H1.add_edges_from([(({1, 3}, {2}), 0)]) + assert H1._edge_out == {0: {1, 2}, 1: {2, 3, 4}} + + +def test_add_edges_from_format3(): + edges = [ + (({0, 1}, {2}), {"color": "red"}), + (({1, 2}, {4}), {"age": 30}), + (({2, 3, 4}, {1}), {"color": "blue", "age": 40}), + ] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert list(H.edges) == list(range(len(edges))) + assert H.edges.dimembers() == [(e[0][0], e[0][1]) for e in edges] + for idx, e in enumerate(H.edges): + assert H.edges[e] == edges[idx][1] + # check counter + H.add_edge(([1, 9, 2], [10])) + assert H.edges.members(3) == {1, 2, 9, 10} + + +def test_add_edges_from_format4(): + edges = [ + (({0, 1}, {2}), "one", {"color": "red"}), + (({1, 2}, {4}), "two", {"age": 30}), + (({2, 3, 4}, {1}), "three", {"color": "blue", "age": 40}), + ] + H = xgi.DiHypergraph() + H.add_edges_from(edges) + assert list(H.edges) == [e[1] for e in edges] + assert H.edges.dimembers() == [(e[0][0], e[0][1]) for e in edges] + for idx, e in enumerate(H.edges): + assert H.edges[e] == edges[idx][2] + # check counter + H.add_edge(([1, 9, 2], [10])) + assert H.edges.members(0) == {1, 2, 9, 10} + + H1 = xgi.DiHypergraph([({1, 2}, {3}), ({2, 3, 4}, {1})]) + with pytest.warns(Warning): + H1.add_edges_from([(({0, 1}, {2}), 0, {"color": "red"})]) + assert H1._edge_out == {0: {1, 2}, 1: {2, 3, 4}} + + +def test_add_edges_from_dict(diedgedict1): + H = xgi.DiHypergraph() + H.add_edges_from(diedgedict1) + assert list(H.edges) == [0, 1] + assert H.edges.members() == [{1, 2, 3, 4}, {5, 6, 7, 8}] + # check counter + H.add_edge(([1, 9, 2], [10])) + assert H.edges.members(2) == {1, 2, 9, 10} + + H1 = xgi.DiHypergraph([({1, 2}, {2, 3, 4})]) + with pytest.warns(Warning): + H1.add_edges_from({0: ({1, 3}, {2})}) + assert H1.edges.dimembers(0) == ({1, 2}, {2, 3, 4}) + + +def test_add_edges_from_attr_precedence(): + H = xgi.DiHypergraph() + edges = [ + (([0, 1], [2]), "one", {"color": "red"}), + (([1, 2], [0]), "two", {"age": 30}), + (([2, 3, 4], [5]), "three", {"color": "blue", "age": 40}), + ] + H.add_edges_from(edges, color="black") + assert H.edges["one"] == {"color": "red"} + assert H.edges["two"] == {"age": 30, "color": "black"} + assert H.edges["three"] == {"age": 40, "color": "blue"} + + +def test_remove_edge(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + H.remove_edge(0) + assert 0 not in H.edges + assert 1 in H and 2 in H and 3 in H and 4 in H + assert H.nodes.memberships() == { + 1: set(), + 2: set(), + 3: set(), + 4: set(), + 5: {1}, + 6: {1}, + 7: {1}, + 8: {1}, + } + + with pytest.raises(IDNotFound): + H.edges[0] + + +def test_remove_edges_from(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + H.remove_edges_from([1, 2]) + assert 0 in H.edges + assert 1 not in H.edges and 2 not in H.edges + assert sorted(H.nodes) == list(range(6)) + assert H.nodes.memberships(2) == {0} + + +def test_copy(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + H["key"] = "value" + copy = H.copy() + assert list(copy.nodes) == list(H.nodes) + assert list(copy.edges) == list(H.edges) + assert list(copy.edges.members()) == list(H.edges.members()) + assert H._hypergraph == copy._hypergraph + + H.add_node(10) + assert list(copy.nodes) != list(H.nodes) + assert list(copy.edges) == list(H.edges) + + H.add_edge(([1, 3, 5], [6])) + assert list(copy.edges) != list(H.edges) + + H["key2"] = "value2" + assert H._hypergraph != copy._hypergraph + + copy.add_node(10) + copy.add_edge(([1, 3, 5], [6])) + copy["key2"] = "value2" + assert list(copy.nodes) == list(H.nodes) + assert list(copy.edges) == list(H.edges) + assert list(copy.edges.members()) == list(H.edges.members()) + assert H._hypergraph == copy._hypergraph + + H1 = xgi.DiHypergraph() + H1.add_edge(([1, 2], [3]), id="x") + copy2 = H1.copy() # does not throw error because of str id + assert list(copy2.nodes) == list(H1.nodes) + assert list(copy2.edges) == list(H1.edges) + assert list(copy2.edges.members()) == list(H1.edges.members()) + assert H1._hypergraph == copy2._hypergraph + + +def test_copy_issue128(): + # see https://github.com/xgi-org/xgi/issues/128 + H = xgi.DiHypergraph() + H["key"] = "value" + K = H.copy() + K["key"] = "some_other_value" + assert H["key"] == "value" + + +def test_remove_node_weak(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + + # node in the tail + assert 1 in H + H.remove_node(1) + assert 1 not in H + + # node in the head + assert 8 in H + H.remove_node(8) + assert 8 not in H + + H = xgi.DiHypergraph(diedgelist1) + + # node in both head and tail + assert 6 in H + H.remove_node(6) + assert 6 not in H + + with pytest.raises(IDNotFound): + H.remove_node(10) + + # test empty edge removal + H = xgi.DiHypergraph(diedgelist1) + H.remove_node(1) + H.remove_node(2) + H.remove_node(3) + H.remove_node(4) + + assert 0 not in H.edges + + # test multiple edge removal with a single node. + H = xgi.DiHypergraph(diedgelist2) + H.remove_node(0) + H.remove_node(1) + + H.remove_node(3) + H.remove_node(4) + H.remove_node(5) + + assert H.num_edges == 3 + + # this removes three edges at once + H.remove_node(2) + + assert H.num_edges == 0 + + +def test_remove_node_strong(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + + # node in the tail + assert 1 in H + H.remove_node(1, strong=True) + assert 1 not in H + + assert 0 not in H.edges + + # node in the head + assert 8 in H + H.remove_node(8, strong=True) + assert 8 not in H + + assert 1 not in H.edges + + H = xgi.DiHypergraph(diedgelist1) + + # node in both head and tail + assert 6 in H + H.remove_node(6, strong=True) + # assert 6 not in H + + # assert 1 not in H.edges + + +def test_remove_nodes_from(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + + H.remove_nodes_from([1, 2, 3]) + assert 1 not in H and 2 not in H and 3 not in H + + with pytest.warns(Warning): + H.remove_nodes_from([1, 2, 3]) + + +def test_pickle(diedgelist1): + _, filename = tempfile.mkstemp() + H1 = xgi.DiHypergraph(diedgelist1) + + with open(filename, "wb") as file: + pickle.dump(H1, file) + with open(filename, "rb") as file: + H2 = pickle.load(file) + + assert H1.nodes == H2.nodes + assert H1.edges == H2.edges + assert [H1.edges.members(id) for id in H1.edges] == [ + H2.edges.members(id) for id in H2.edges + ] diff --git a/tests/classes/test_directed_reportviews.py b/tests/classes/test_directed_reportviews.py new file mode 100644 index 000000000..7cdf7010b --- /dev/null +++ b/tests/classes/test_directed_reportviews.py @@ -0,0 +1,225 @@ +import numpy as np +import pytest + +import xgi +from xgi.exception import IDNotFound, XGIError + + +def test_edge_order(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + + with pytest.raises(TypeError): + H.edges() + + assert len(H.edges.filterby("order", 3)) == 2 + assert len(H.edges.filterby("order", 2)) == 0 + + ord2 = H.edges.filterby("order", 3) + assert len(ord2) == 2 + assert (0 in ord2) and (1 in ord2) + + H.add_edge(([3, 7, 8, 9, 10], [11, 12])) + + assert len(H.edges.filterby("order", 6)) == 1 + assert 2 in H.edges.filterby("order", 6) + + +def test_node_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + with pytest.raises(TypeError): + H.edges() + + assert H.degree() == {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1} + assert H.degree(1) == 2 + assert H.degree(2) == 3 + with pytest.raises(KeyError): + H.degree(-1) + + assert len(H.nodes.filterby("degree", 1)) == 3 + assert len(H.nodes.filterby("degree", 3)) == 1 + deg2 = H.nodes.filterby("degree", 2) + assert len(deg2) == 2 + assert (1 in deg2) and (4 in deg2) + + H.add_edge(([3, 7], [9, 10])) + + assert len(H.nodes.filterby("degree", 2)) == 3 + assert len(H.nodes.filterby("degree", 3)) == 1 + assert 3 in H.nodes.filterby("degree", 2) + assert 7 in H.nodes.filterby("degree", 1) + + +def test_size_degree(diedgelist1, diedgelist2): + H1 = xgi.DiHypergraph(diedgelist1) + H2 = xgi.DiHypergraph(diedgelist2) + + assert H1.edges.size.asdict() == {0: 4, 1: 4} + assert H2.edges.size.asdict() == {0: 3, 1: 3, 2: 4} + assert H1.edges.order.asdict() == {0: 3, 1: 3} + assert H2.edges.order.asdict() == {0: 2, 1: 2, 2: 3} + + +def test_degree(diedgelist1, diedgelist2): + H1 = xgi.DiHypergraph(diedgelist1) + H2 = xgi.DiHypergraph(diedgelist2) + # test basic functionality + assert H1.degree(1) == 1 + assert H1.degree(2) == 1 + assert H1.degree(3) == 1 + with pytest.raises(KeyError): + H1.degree(0) + + # check len + assert len(H1.degree()) == 8 + + # test order + assert H2.nodes.degree(order=1).asdict() == {0: 0, 1: 0, 2: 0, 4: 0, 3: 0, 5: 0} + assert H2.nodes.degree(order=2).asdict() == {0: 1, 1: 2, 2: 2, 4: 1, 3: 0, 5: 0} + assert H2.nodes.degree(order=3).asdict() == {0: 0, 1: 0, 2: 1, 4: 1, 3: 1, 5: 1} + + # test weights + attr_dict1 = {0: {"weight": -2}, 1: {"weight": 4.0}, 2: {"weight": 0.3}} + xgi.set_edge_attributes(H2, attr_dict1) + + assert H2.nodes.degree(weight="weight").asdict() == { + 0: -2, + 1: 2.0, + 2: 2.3, + 4: 4.3, + 3: 0.3, + 5: 0.3, + } + assert H2.nodes.degree(weight="weight", order=2).asdict() == { + 0: -2, + 1: 2.0, + 2: 2.0, + 4: 4.0, + 3: 0, + 5: 0, + } + + +def test_edge_members(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.members(0) == {0, 1, 2} + assert H.edges.members() == [{0, 1, 2}, {1, 2, 4}, {2, 3, 4, 5}] + assert H.edges.members(dtype=dict) == {0: {0, 1, 2}, 1: {1, 2, 4}, 2: {2, 3, 4, 5}} + with pytest.raises(XGIError): + H.edges.members(dtype=np.array) + + with pytest.raises(TypeError): + H.edges.members(slice(1, 4, 1)) + + with pytest.raises(TypeError): + H.edges.members([1, 2]) + + with pytest.raises(IDNotFound): + H.edges.members("test") + + +def test_edge_dimembers(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.dimembers(0) == ({0, 1}, {2}) + assert H.edges.dimembers() == [({0, 1}, {2}), ({1, 2}, {4}), ({2, 3, 4}, {4, 5})] + assert H.edges.dimembers(dtype=dict) == { + 0: ({0, 1}, {2}), + 1: ({1, 2}, {4}), + 2: ({2, 3, 4}, {4, 5}), + } + with pytest.raises(XGIError): + H.edges.dimembers(dtype=np.array) + + with pytest.raises(TypeError): + H.edges.dimembers(slice(1, 4, 1)) + + with pytest.raises(TypeError): + H.edges.dimembers([1, 2]) + + with pytest.raises(IDNotFound): + H.edges.dimembers("test") + + +def test_edge_tail(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + assert H.edges.tail(0) == {0, 1} + assert H.edges.tail() == [{0, 1}, {1, 2}, {2, 3, 4}] + assert H.edges.tail(dtype=dict) == {0: {0, 1}, 1: {1, 2}, 2: {2, 3, 4}} + + with pytest.raises(XGIError): + H.edges.tail(dtype=np.array) + + with pytest.raises(TypeError): + H.edges.tail(slice(1, 4, 1)) + + with pytest.raises(TypeError): + H.edges.tail([1, 2]) + + with pytest.raises(IDNotFound): + H.edges.tail("test") + + +def test_edge_head(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + assert H.edges.head(0) == {2} + assert H.edges.head() == [{2}, {4}, {4, 5}] + assert H.edges.head(dtype=dict) == {0: {2}, 1: {4}, 2: {4, 5}} + with pytest.raises(XGIError): + H.edges.head(dtype=np.array) + + with pytest.raises(TypeError): + H.edges.head(slice(1, 4, 1)) + + with pytest.raises(TypeError): + H.edges.head([1, 2]) + + with pytest.raises(IDNotFound): + H.edges.head("test") + + +def test_view_len(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + nodes = H.nodes + assert len(nodes) == len(H._node_in) + H.add_node(10) + assert len(nodes) == len(H._node_in) + H.add_nodes_from(range(10, 20)) + assert len(nodes) == len(H._node_in) + + +def test_bunch_view(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + bunch_view = H.edges.from_view(H.edges, bunch=[1, 2]) + assert len(bunch_view) == 2 + assert (1 in bunch_view) and (2 in bunch_view) + assert 0 not in bunch_view + assert bunch_view.members(dtype=dict) == {1: {1, 2, 4}, 2: {2, 3, 4, 5}} + with pytest.raises(IDNotFound): + bunch_view.members(0) + + +def test_call_wrong_bunch(): + H = xgi.DiHypergraph() + with pytest.raises(IDNotFound): + H.nodes([0]) + + H.add_node(0) + assert len(H.nodes([0])) + with pytest.raises(TypeError): + H.nodes(0) + + +def test_call(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + assert len(H.nodes([])) == 0 + assert H.nodes(list(H.nodes)) == H.nodes + assert H.nodes(H.nodes) == H.nodes + + +def test_bool(diedgelist1): + H = xgi.DiHypergraph([]) + assert bool(H.edges) is False + H = xgi.DiHypergraph(diedgelist1) + assert bool(H.edges) is True diff --git a/tests/classes/test_reportviews.py b/tests/classes/test_reportviews.py index c66c712c4..c68d4e122 100644 --- a/tests/classes/test_reportviews.py +++ b/tests/classes/test_reportviews.py @@ -167,6 +167,9 @@ def test_edge_members(edgelist3): with pytest.raises(IDNotFound): H.edges.members("test") + +def test_members_read_only(edgelist3): + H = xgi.Hypergraph(edgelist3) # test that members are copies in memory H.edges.members(0).add("a") assert "a" not in H.edges.members(0) @@ -180,10 +183,10 @@ def test_edge_members(edgelist3): assert "a" not in H.edges.members(0) -def test_node_memberships(edgelist3): +def test_memberships_read_only(edgelist3): H = xgi.Hypergraph(edgelist3) - # test that members are copies in memory + # test that memberships are copies in memory H.nodes.memberships(1).add("a") assert "a" not in H.nodes.memberships(1) diff --git a/tests/conftest.py b/tests/conftest.py index 64a137408..4a7088ebe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,6 +135,11 @@ def bipartite_graph4(): return G +@pytest.fixture +def attr0(): + return {"color": "brown", "name": "camel"} + + @pytest.fixture def attr1(): return {"color": "red", "name": "horse"} @@ -212,3 +217,36 @@ def hypergraph2(): H.add_nodes_from(["b", "c", 0]) H.add_edges_from({"e1": [0, "b"], "e2": [0, "c"], "e3": [0, "b", "c"]}) return H + + +@pytest.fixture +def diedgelist1(): + return [({1, 2, 3}, {4}), ({5, 6}, {6, 7, 8})] + + +@pytest.fixture +def diedgelist2(): + return [({0, 1}, {2}), ({1, 2}, {4}), ({2, 3, 4}, {4, 5})] + + +@pytest.fixture +def diedgedict1(): + return {0: ({1, 2, 3}, {4}), 1: ({5, 6}, {6, 7, 8})} + + +@pytest.fixture +def dihyperwithattrs(diedgelist2, attr0, attr1, attr2, attr3, attr4, attr5): + H = xgi.DiHypergraph() + H.add_nodes_from( + [ + (0, attr0), + (1, attr1), + (2, attr2), + (3, attr3), + (4, attr4), + (5, attr5), + ] + ) + H.add_edges_from(diedgelist2) + xgi.set_edge_attributes(H, {0: attr3, 1: attr4, 2: attr5}) + return H diff --git a/tests/generators/test_classic.py b/tests/generators/test_classic.py index c24316a30..1febcce5a 100644 --- a/tests/generators/test_classic.py +++ b/tests/generators/test_classic.py @@ -10,6 +10,11 @@ def test_empty_hypergraph(): assert (H.num_nodes, H.num_edges) == (0, 0) +def test_empty_dihypergraph(): + H = xgi.empty_dihypergraph() + assert (H.num_nodes, H.num_edges) == (0, 0) + + def test_empty_simplicial_complex(): SC = xgi.empty_simplicial_complex() assert (SC.num_nodes, SC.num_edges) == (0, 0) diff --git a/tests/stats/test_diedgestats.py b/tests/stats/test_diedgestats.py new file mode 100644 index 000000000..65397e3d8 --- /dev/null +++ b/tests/stats/test_diedgestats.py @@ -0,0 +1,178 @@ +import pytest + +import xgi + + +def test_filterby_wrong_stat(): + H = xgi.DiHypergraph() + with pytest.raises(AttributeError): + H.edges.filterby("__I_DO_NOT_EXIST__", None) + + +def test_filterby(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.filterby("order", 2).size.asdict() == {0: 3, 1: 3} + assert H.edges.filterby("size", 4).order.asdict() == {2: 3} + + +def test_call_filterby(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.edges([1, 2]).filterby("order", 2).size.asdict() == {1: 3} + + +def test_filterby_with_nodestat(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert list(H.edges.filterby(H.edges.order(degree=1), 3)) == [0, 1] + + H = xgi.DiHypergraph(diedgelist2) + assert list(H.edges.filterby(H.edges.order(degree=2), 1)) == [1] + + +def test_single_node(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + assert H.order()[1] == 3 + assert H.edges.order[1] == 3 + with pytest.raises(KeyError): + H.order()[-1] + with pytest.raises(KeyError): + H.edges.order[-1] + + +def test_aggregates(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert H.edges.order.max() == 3 + assert H.edges.order.min() == 3 + assert H.edges.order.sum() == 6 + assert round(H.edges.order.mean(), 3) == 3 + assert round(H.edges.order.std(), 3) == 0 + assert round(H.edges.order.var(), 3) == 0 + + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.order.max() == 3 + assert H.edges.order.min() == 2 + assert H.edges.order.sum() == 7 + assert round(H.edges.order.mean(), 3) == 2.333 + assert round(H.edges.order.std(), 3) == 0.471 + assert round(H.edges.order.var(), 3) == 0.222 + + +def test_stats_items(diedgelist2): + d = {0: 2, 1: 2, 2: 3} + H = xgi.DiHypergraph(diedgelist2) + for e, s in H.edges.order.items(): + assert d[e] == s + + +def test_stats_are_views(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + es = H.edges.order + assert es.asdict() == {0: 2, 1: 2, 2: 3} + H.add_edge(([3, 4, 5], [6])) + assert es.asdict() == {0: 2, 1: 2, 2: 3, 3: 3} + + +def test_different_views(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + with pytest.raises(KeyError): + H.edges.multi(["order", H.edges([1, 2]).size]).asdict() + with pytest.raises(KeyError): + H.edges.multi(["order", H.edges([1, 2]).attrs("color")]).asdict() + + +def test_user_defined(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + with pytest.raises(AttributeError): + H.user_order + with pytest.raises(AttributeError): + H.edges.user_order + + @xgi.diedgestat_func + def user_order(net, bunch): + return {n: 10 * net.order(n) for n in bunch} + + vals = {n: 10 * H.order(n) for n in H.edges} + assert H.user_order() == vals + assert H.edges.user_order.asdict() == vals + assert H.edges.user_order.aslist() == [vals[n] for n in H.edges] + assert H.edges.filterby("order", 2).user_order.asdict() == {0: 20, 1: 20} + + +def test_view_val(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert H.edges.order._val == {0: 3, 1: 3} + assert H.edges([1]).order._val == {1: 3} + + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.order._val == {0: 2, 1: 2, 2: 3} + assert H.edges([1, 2]).order._val == {1: 2, 2: 3} + + +# test the pre-defined edgestat methods +def test_attrs(dihyperwithattrs): + c = dihyperwithattrs.edges.attrs("color") + assert c.asdict() == {0: "yellow", 1: "red", 2: "blue"} + c = dihyperwithattrs.edges.attrs("age", missing=100) + assert c.asdict() == {0: 100, 1: 20, 2: 2} + c = dihyperwithattrs.edges.attrs() + assert c.asdict() == { + 0: {"color": "yellow", "name": "zebra"}, + 1: {"color": "red", "name": "orangutan", "age": 20}, + 2: {"color": "blue", "name": "fish", "age": 2}, + } + with pytest.raises(ValueError): + dihyperwithattrs.edges.attrs(attr=100).asdict() + + +def test_order(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.order.asdict() == {0: 2, 1: 2, 2: 3} + + assert H.edges.order(degree=1).asdict() == {0: 0, 1: -1, 2: 1} + + +def test_size(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.size.asdict() == {0: 3, 1: 3, 2: 4} + + assert H.edges.size(degree=1).asdict() == {0: 1, 1: 0, 2: 2} + + +def test_tail_order(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert H.edges.tail_order.asdict() == {0: 2, 1: 1} + + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.tail_order.asdict() == {0: 1, 1: 1, 2: 2} + + assert H.edges.tail_order(degree=1).asdict() == {0: 0, 1: -1, 2: 0} + + +def test_tail_size(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert H.edges.tail_size.asdict() == {0: 3, 1: 2} + + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.tail_size.asdict() == {0: 2, 1: 2, 2: 3} + + assert H.edges.tail_size(degree=1).asdict() == {0: 1, 1: 0, 2: 1} + + +def test_head_order(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert H.edges.head_order.asdict() == {0: 0, 1: 2} + + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.head_order.asdict() == {0: 0, 1: 0, 2: 1} + + assert H.edges.head_order(degree=1).asdict() == {0: -1, 1: -1, 2: 0} + + +def test_head_size(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + assert H.edges.head_size.asdict() == {0: 1, 1: 3} + + H = xgi.DiHypergraph(diedgelist2) + assert H.edges.head_size.asdict() == {0: 1, 1: 1, 2: 2} + + assert H.edges.head_size(degree=1).asdict() == {0: 0, 1: 0, 2: 1} diff --git a/tests/stats/test_dinodestats.py b/tests/stats/test_dinodestats.py new file mode 100644 index 000000000..4d231196d --- /dev/null +++ b/tests/stats/test_dinodestats.py @@ -0,0 +1,243 @@ +import numpy as np +import pandas as pd +import pytest + +import xgi + + +def test_filterby_wrong_stat(): + H = xgi.DiHypergraph() + with pytest.raises(AttributeError): + H.nodes.filterby("__I_DO_NOT_EXIST__", None) + + +def test_filterby(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.nodes.filterby("degree", 2).in_degree.asdict() == {1: 2, 4: 1} + assert H.nodes.filterby("degree", 2).out_degree.asdict() == {1: 0, 4: 2} + assert H.nodes.filterby("in_degree", 1).degree.asdict() == {0: 1, 3: 1, 4: 2} + assert H.nodes.filterby("out_degree", 1).degree.asdict() == {2: 3, 5: 1} + + H = xgi.DiHypergraph(diedgelist1) + assert H.nodes.filterby("degree", 1).in_degree.asdict() == { + 1: 1, + 2: 1, + 3: 1, + 4: 0, + 5: 1, + 6: 1, + 7: 0, + 8: 0, + } + assert H.nodes.filterby("degree", 1).out_degree.asdict() == { + 1: 0, + 2: 0, + 3: 0, + 4: 1, + 5: 0, + 6: 1, + 7: 1, + 8: 1, + } + assert H.nodes.filterby("in_degree", 1).degree.asdict() == { + 1: 1, + 2: 1, + 3: 1, + 5: 1, + 6: 1, + } + assert H.nodes.filterby("out_degree", 1).degree.asdict() == {8: 1, 4: 1, 6: 1, 7: 1} + + +def test_filterby_with_nodestat(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert list(H.nodes.filterby(H.nodes.degree(order=2), 1)) == [0, 4] + + +def test_filterby_modes(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert list(H.nodes.filterby("degree", 2)) == [1, 4] + assert list(H.nodes.filterby("degree", 2, "eq")) == [1, 4] + assert list(H.nodes.filterby("degree", 1, "neq")) == [1, 2, 4] + assert list(H.nodes.filterby("degree", 3, "geq")) == [2] + assert list(H.nodes.filterby("degree", 3, "gt")) == [] + assert list(H.nodes.filterby("degree", 0, "leq")) == [] + assert list(H.nodes.filterby("degree", 1, "lt")) == [] + assert set(H.nodes.filterby("degree", (1, 3), "between")) == set(H.nodes) + + +def test_call_filterby(diedgelist1, diedgelist2): + H = xgi.DiHypergraph(diedgelist1) + + filtered = H.nodes([4, 5, 6]).filterby("in_degree", 1).degree + assert filtered.asdict() == {5: 1, 6: 1} + + H = xgi.DiHypergraph(diedgelist2) + assert set(H.nodes([1, 2, 3]).filterby("degree", 2)) == {1} + + +def test_single_node(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + assert H.degree()[1] == 1 + assert H.nodes.degree[1] == 1 + with pytest.raises(KeyError): + H.degree()[-1] + with pytest.raises(KeyError): + H.nodes.degree[-1] + + +def test_stats_items(diedgelist2): + deg = {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1} + H = xgi.DiHypergraph(diedgelist2) + for n, d in H.nodes.degree.items(): + assert deg[n] == d + + +def test_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + degs = {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1} + assert H.degree() == degs + assert H.degree(order=2) == {0: 1, 1: 2, 2: 2, 4: 1, 3: 0, 5: 0} + assert H.nodes.degree.asdict() == degs + assert H.nodes.degree.aslist() == list(degs.values()) + + +def test_in_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + degs = {0: 1, 1: 2, 2: 2, 4: 1, 3: 1, 5: 0} + assert H.in_degree() == degs + assert H.in_degree(order=2) == {0: 1, 1: 2, 2: 1, 4: 0, 3: 0, 5: 0} + assert H.nodes.in_degree.asdict() == degs + assert H.nodes.in_degree.aslist() == list(degs.values()) + + +def test_out_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + degs = {0: 0, 1: 0, 2: 1, 4: 2, 3: 0, 5: 1} + assert H.out_degree() == degs + assert H.out_degree(order=2) == {0: 0, 1: 0, 2: 1, 4: 1, 3: 0, 5: 0} + assert H.nodes.out_degree.asdict() == degs + assert H.nodes.out_degree.aslist() == list(degs.values()) + + +def test_aggregates(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.nodes.degree.max() == 3 + assert H.nodes.degree.min() == 1 + assert H.nodes.degree.sum() == 10 + assert round(H.nodes.degree.mean(), 3) == 1.667 + assert round(H.nodes.degree.std(), 3) == 0.745 + assert round(H.nodes.degree.var(), 3) == 0.556 + + +def test_stats_are_views(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + ns = H.nodes.degree + assert ns.asdict() == {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1} + H.add_node(10) + assert ns.asdict() == {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1, 10: 0} + + +def test_after_call_with_args(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + degs = {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1} + assert H.nodes.degree.asdict() == degs + + # check when calling without arguments AFTER calling WITH arguments + H.nodes.degree(order=2).asdict() + assert H.nodes.degree.asdict() == degs + + +def test_different_views(diedgelist1): + H = xgi.DiHypergraph(diedgelist1) + with pytest.raises(KeyError): + H.nodes.multi([H.nodes.degree, H.nodes([1, 2]).degree]).asdict() + with pytest.raises(KeyError): + H.nodes.multi([H.nodes.attrs("color"), H.nodes([1, 2]).attrs("color")]).asdict() + + +def test_user_defined(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + with pytest.raises(AttributeError): + H.user_degree + with pytest.raises(AttributeError): + H.nodes.user_degree + + @xgi.dinodestat_func + def user_degree(net, bunch): + return {n: 10 * net.degree(n) for n in bunch} + + vals = {n: 10 * H.degree(n) for n in H} + assert H.user_degree() == vals + assert H.nodes.user_degree.asdict() == vals + assert H.nodes.user_degree.aslist() == [vals[n] for n in H] + assert ( + list(H.nodes.filterby("user_degree", 20)) + == list(H.nodes.filterby("degree", 2)) + == [1, 4] + ) + assert H.nodes.filterby("degree", 2).user_degree.asdict() == {1: 20, 4: 20} + + +def test_view_val(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + assert H.nodes.degree._val == {0: 1, 1: 2, 2: 3, 4: 2, 3: 1, 5: 1} + assert H.nodes([1, 2]).degree._val == {1: 2, 2: 3} + + +def test_moment(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + deg = H.nodes.degree + assert round(deg.moment(), 3) == 3.333 + assert round(deg.moment(2, center=False), 3) == 3.333 + assert round(deg.moment(2, center=True), 3) == 0.556 + assert round(deg.moment(3, center=False), 3) == 7.667 + assert round(deg.moment(3, center=True), 3) == 0.259 + + +# test pre-defined functions +def test_attrs(dihyperwithattrs): + c = dihyperwithattrs.nodes.attrs("color") + assert c.asdict() == { + 0: "brown", + 1: "red", + 2: "blue", + 3: "yellow", + 4: "red", + 5: "blue", + } + c = dihyperwithattrs.nodes.attrs("age", missing=100) + assert c.asdict() == {0: 100, 1: 100, 2: 100, 3: 100, 4: 20, 5: 2} + c = dihyperwithattrs.nodes.attrs() + assert c.asdict() == { + 0: {"color": "brown", "name": "camel"}, + 1: {"color": "red", "name": "horse"}, + 2: {"color": "blue", "name": "pony"}, + 3: {"color": "yellow", "name": "zebra"}, + 4: {"color": "red", "name": "orangutan", "age": 20}, + 5: {"color": "blue", "name": "fish", "age": 2}, + } + with pytest.raises(ValueError): + dihyperwithattrs.nodes.attrs(attr=100).asdict() + + +def test_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + assert H.nodes.degree.asdict() == {0: 1, 1: 2, 2: 3, 3: 1, 4: 2, 5: 1} + assert H.nodes.degree(order=2).asdict() == {0: 1, 1: 2, 2: 2, 3: 0, 4: 1, 5: 0} + + +def test_in_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + assert H.nodes.in_degree.asdict() == {0: 1, 1: 2, 2: 2, 3: 1, 4: 1, 5: 0} + assert H.nodes.in_degree(order=2).asdict() == {0: 1, 1: 2, 2: 1, 3: 0, 4: 0, 5: 0} + + +def test_out_degree(diedgelist2): + H = xgi.DiHypergraph(diedgelist2) + + assert H.nodes.out_degree.asdict() == {0: 0, 1: 0, 2: 1, 3: 0, 4: 2, 5: 1} + assert H.nodes.out_degree(order=2).asdict() == {0: 0, 1: 0, 2: 1, 3: 0, 4: 1, 5: 0} diff --git a/tests/test_convert.py b/tests/test_convert.py index 9f68197d6..38b787c1d 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -12,6 +12,12 @@ def test_convert_empty_hypergraph(): assert H.num_edges == 0 +def test_convert_empty_dihypergraph(): + H = xgi.convert_to_dihypergraph(None) + assert H.num_nodes == 0 + assert H.num_edges == 0 + + def test_convert_simplicial_complex_to_hypergraph(): SC = xgi.SimplicialComplex() SC.add_simplices_from([[3, 4, 5], [3, 6], [6, 7, 8, 9], [1, 4, 10, 11, 12], [1, 4]]) @@ -50,6 +56,14 @@ def test_convert_hypergraph_to_simplicial_complex(): assert H.edges.members() == SC.edges.maximal().members() +def test_convert_dihypergraph_to_hypergraph(diedgelist2): + DH = xgi.DiHypergraph(diedgelist2) + H = xgi.convert_to_hypergraph(DH) + assert isinstance(H, xgi.Hypergraph) + assert H.nodes == DH.nodes + assert H.edges.members() == [{0, 1, 2}, {1, 2, 4}, {2, 3, 4, 5}] + + def test_convert_list_to_simplicial_complex(edgelist2): SC = xgi.convert_to_simplicial_complex(edgelist2) assert isinstance(SC, xgi.SimplicialComplex) diff --git a/tests/utils/test_iddict.py b/tests/utils/test_iddict.py deleted file mode 100644 index 3ec5d7f60..000000000 --- a/tests/utils/test_iddict.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest - -import xgi -from xgi.exception import IDNotFound, XGIError - - -def test_iddict(edgelist1): - H = xgi.Hypergraph() - with pytest.raises(IDNotFound): - H.nodes[0] - with pytest.raises(IDNotFound): - H.edges[0] - - H = xgi.Hypergraph(edgelist1) - with pytest.raises(IDNotFound): - H.nodes[0] - with pytest.raises(IDNotFound): - H.edges[4] - - assert H.edges[0] == dict() - - with pytest.raises(XGIError): - H._edge[None] = {1, 2, 3} - - with pytest.raises(IDNotFound): - del H._node["test"] - - with pytest.raises(TypeError): - H._node[[0, 1, 2]] = [0, 1] - - -def test_neighbors(): - H = xgi.Hypergraph() - with pytest.raises(IDNotFound): - H.nodes.neighbors(0) - with pytest.raises(IDNotFound): - H.remove_node(0) - with pytest.raises(IDNotFound): - H.remove_edge(0) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index e76cea04c..ec8ea1889 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -1,7 +1,44 @@ import networkx as nx +import pytest from numpy import infty import xgi +from xgi.exception import IDNotFound, XGIError + + +def test_iddict(edgelist1): + H = xgi.Hypergraph() + with pytest.raises(IDNotFound): + H.nodes[0] + with pytest.raises(IDNotFound): + H.edges[0] + + H = xgi.Hypergraph(edgelist1) + with pytest.raises(IDNotFound): + H.nodes[0] + with pytest.raises(IDNotFound): + H.edges[4] + + assert H.edges[0] == dict() + + with pytest.raises(XGIError): + H._edge[None] = {1, 2, 3} + + with pytest.raises(IDNotFound): + del H._node["test"] + + with pytest.raises(TypeError): + H._node[[0, 1, 2]] = [0, 1] + + +def test_neighbors(): + H = xgi.Hypergraph() + with pytest.raises(IDNotFound): + H.nodes.neighbors(0) + with pytest.raises(IDNotFound): + H.remove_node(0) + with pytest.raises(IDNotFound): + H.remove_edge(0) def test_dual_dict(dict5): diff --git a/tutorials/Tutorial 8 - Directed Hypergraphs.ipynb b/tutorials/Tutorial 8 - Directed Hypergraphs.ipynb new file mode 100644 index 000000000..ddefac7bd --- /dev/null +++ b/tutorials/Tutorial 8 - Directed Hypergraphs.ipynb @@ -0,0 +1,444 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tutorial 8 - Directed Hypergraphs" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import xgi" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A *directed hypergraph* (or *dihypergraph*), is a hypergraph which keeps track of senders and receivers in a given interaction. As defined in \"Hypergraph Theory: An Introduction\" by Alain Bretto, dihypergraphs are a set of nodes and a set of directed edges.\n", + "\n", + "We define a directed hyperedge $\\overrightarrow{e_i} \\in E$ as an ordered pair $(e^+_i, e^-_i)$, where the *tail* of the edge, $e^+_i$, is the set of senders and the *head*, $e^-_i$, is the set of receivers. Both are subsets of the node set. We define the members of $\\overrightarrow{e_i}$ as $e_i = e^+_i \\cup e^-_i$ and the edge size as $s_i = |e_i|$. Likewise, we define the in-degree, out-degree, and degree of a node $i$ as\n", + "$$k^{in}_i = \\sum_j^M {\\bf 1}(i \\in e^-_j),$$\n", + "$$k^{out}_i = \\sum_j^M {\\bf 1}(i \\in e^+_j),$$\n", + "$$k_i = \\sum_j^M {\\bf 1}(i \\in e_j),$$\n", + "respectively, where ${\\bf 1}$ is the indicator function.\n", + "\n", + "These types of hypergraphs are useful for representing, for example, chemical reactions (which have reactants and products) and emails (sender and receivers).\n", + "\n", + "We start by building a dihypergraph." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Building a dihypergraph\n", + "\n", + "We can either build a dihypergraph node-by-node and edge-by-edge, or we can initialize a dihypergraph through its constructor.\n", + "\n", + "We start by building a dihypergraph from the bottom up." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Unnamed DiHypergraph with 0 nodes and 0 hyperedges\n", + "Now that we've added nodes and edges, we have a DiHypergraph named test with 8 nodes and 3 hyperedges\n" + ] + } + ], + "source": [ + "DH = xgi.DiHypergraph()\n", + "print(DH)\n", + "\n", + "DH.add_node(0, name=\"test\")\n", + "DH.add_edge(\n", + " [{1, 2, 3}, {3, 4}]\n", + ") # Notice that the head and the tail need not be disjoint.\n", + "\n", + "DH.add_nodes_from([5, 6, 7])\n", + "edges = [[{1, 2}, {5, 6}], [{4}, {1, 3}]]\n", + "DH.add_edges_from(edges)\n", + "DH[\"name\"] = \"test\"\n", + "\n", + "print(\"Now that we've added nodes and edges, we have a \" + str(DH))" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also add edge with attributes!" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "edges = [\n", + " (([0, 1], [1, 2]), \"one\", {\"color\": \"red\"}),\n", + " (([2, 3, 4], []), \"two\", {\"color\": \"blue\", \"age\": 40}),\n", + "]\n", + "DH.add_edges_from(edges)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also use the constructor to initialize a dihypergraph:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# from a list\n", + "DH1 = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]])\n", + "\n", + "# from a dict\n", + "DH2 = xgi.DiHypergraph({1: ({1, 2, 3}, {3, 4}), 2: ({1, 2}, {3})})\n", + "\n", + "# from another dihypergraph\n", + "DH3 = xgi.DiHypergraph(DH1)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Views\n", + "\n", + "Nodes and edges are represented by `DiNodeView` and `DiEdgeView` respectively." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DiNodeView((0, 1, 2, 3, 4, 5, 6, 7))" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "DH.nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DiEdgeView((0, 1, 2, 'one', 'two'))" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "DH.edges" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can access directed edges with the `dimembers()` method and the union of the head and tail with `members()`." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Edge 0:\n", + "({1, 2, 3}, {3, 4})\n", + "{1, 2, 3, 4}\n", + "\n", + "The edge list as a whole:\n", + "{0: ({1, 2, 3}, {3, 4}), 1: ({1, 2}, {5, 6}), 2: ({4}, {1, 3}), 'one': ({0, 1}, {1, 2}), 'two': ({2, 3, 4}, set())}\n", + "[{1, 2, 3, 4}, {1, 2, 5, 6}, {1, 3, 4}, {0, 1, 2}, {2, 3, 4}]\n" + ] + } + ], + "source": [ + "print(\"Edge 0:\")\n", + "print(DH.edges.dimembers(0))\n", + "print(DH.edges.members(0))\n", + "print(\"\\nThe edge list as a whole:\")\n", + "print(DH.edges.dimembers())\n", + "print(DH.edges.members())" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The naming convention is the same for node memberships." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "memberships for node 0:\n", + "(set(), {'one'})\n", + "{'one'}\n", + "\n", + "All node memberships:\n", + "{0: (set(), {'one'}), 1: ({'one', 2}, {0, 1, 'one'}), 2: ({'one'}, {0, 1, 'two'}), 3: ({0, 2}, {0, 'two'}), 4: ({0}, {'two', 2}), 5: ({1}, set()), 6: ({1}, set()), 7: (set(), set())}\n", + "{0: {'one'}, 1: {0, 1, 'one', 2}, 2: {0, 1, 'one', 'two'}, 3: {0, 2, 'two'}, 4: {0, 2, 'two'}, 5: {1}, 6: {1}, 7: set()}\n" + ] + } + ], + "source": [ + "print(\"memberships for node 0:\")\n", + "print(DH.nodes.dimemberships(0))\n", + "print(DH.nodes.memberships(0))\n", + "\n", + "print(\"\\nAll node memberships:\")\n", + "print(DH.nodes.dimemberships())\n", + "print(DH.nodes.memberships())" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also access the head and tail of an edge:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Head and tail of edge 0:\n", + "{3, 4}\n", + "{1, 2, 3}\n", + "\n", + "The head as a whole:\n", + "[{3, 4}, {5, 6}, {1, 3}, {1, 2}, set()]\n", + "\n", + "The tail as a whole:\n", + "[{1, 2, 3}, {1, 2}, {4}, {0, 1}, {2, 3, 4}]\n" + ] + } + ], + "source": [ + "print(\"Head and tail of edge 0:\")\n", + "print(DH.edges.head(0))\n", + "print(DH.edges.tail(0))\n", + "\n", + "print(\"\\nThe head as a whole:\")\n", + "print(DH.edges.head())\n", + "\n", + "print(\"\\nThe tail as a whole:\")\n", + "print(DH.edges.tail())" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Stats\n", + "\n", + "The `DiNodeStat` and `DiEdgeStat` represent directed node and edge statistics. For nodes, we have `in_degree`, `out_degree`, and `degree` and for edges, we have `size`, `order`, `head_size`, and `tail_size`." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "s = DH.edges.size.asnumpy()\n", + "s_in = DH.edges.head_size.asnumpy()\n", + "s_out = DH.edges.tail_size.asnumpy()" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plt.plot(s, label=\"edge size\")\n", + "plt.plot(s_in, label=\"head size\")\n", + "plt.plot(s_out, label=\"tail size\")\n", + "plt.legend()\n", + "plt.ylabel(\"Size\")\n", + "plt.xlabel(\"Edge index\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "k = DH.nodes.degree.asnumpy()\n", + "k_in = DH.nodes.in_degree.asnumpy()\n", + "k_out = DH.nodes.out_degree.asnumpy()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plt.plot(k, label=\"degree\")\n", + "plt.plot(k_in, label=\"in-degree\")\n", + "plt.plot(k_out, label=\"out-degree\")\n", + "plt.legend()\n", + "plt.ylabel(\"Degree\")\n", + "plt.xlabel(\"Node ID\")\n", + "plt.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can convert from dihypergraphs to hypergraphs through the constructor..." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "H = xgi.Hypergraph(DH1)\n", + "H.edges.members()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "...or through the convert module." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "H = xgi.convert_to_hypergraph(DH1)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "hyper", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/xgi/algorithms/assortativity.py b/xgi/algorithms/assortativity.py index ed5547d1e..039e12998 100644 --- a/xgi/algorithms/assortativity.py +++ b/xgi/algorithms/assortativity.py @@ -1,4 +1,5 @@ """Algorithms for finding the degree assortativity of a hypergraph.""" + import random from itertools import combinations diff --git a/xgi/algorithms/centrality.py b/xgi/algorithms/centrality.py index c2be4bc5b..b9d883eed 100644 --- a/xgi/algorithms/centrality.py +++ b/xgi/algorithms/centrality.py @@ -1,4 +1,5 @@ """Algorithms for computing the centralities of nodes (and edges) in a hypergraph.""" + from warnings import warn import networkx as nx @@ -252,6 +253,7 @@ def node_edge_centrality( def line_vector_centrality(H): """The vector centrality of nodes in the line graph of the hypergraph. + Parameters ---------- H : Hypergraph @@ -316,19 +318,19 @@ def katz_centrality(H, index=False, cutoff=100): The Katz-centrality measures the relative importance of a node by counting how many distinct walks start from it. The longer the walk is the smaller its contribution will be (attenuation factor `alpha`). - Initialy defined for graphs, the Katz-centrality is here generalized to + Initially defined for graphs, the Katz-centrality is here generalized to hypergraphs using the most basic definition of neighbors : two nodes that share an hyperedge. Parameters ---------- H : xgi.Hypergraph - Hypergraph on which to compute the Kayz-centralities. + Hypergraph on which to compute the Katz-centralities. index : bool If set to `True`, will return a dictionary mapping each vector index to a node. Default value is `False`. cutoff : int - Power at which to stop the serie A + alpha * A**2 + alpha**2 * A**3 + .. + Power at which to stop the series A + alpha * A**2 + alpha**2 * A**3 + .. Default value is 100. Returns @@ -348,15 +350,15 @@ def katz_centrality(H, index=False, cutoff=100): Notes ----- [1] The Katz-centrality is defined as : - c = [(I - alpha.A^{t})^{-1} - I] • (1, 1, ..., 1) - Where A is the adjency matrix of the the (hyper)graph. - Since A^{t} = A for undirected graphs (our case), we have : - (I + A + alpha * A**2 + alpha**2 * A**3 + ...) * (I - alpha.A^{t}) - = (I + A + alpha.A**2 + alpha**2.A**3 + ...) * (I - alpha.A) - = (I + A + alpha.A**2 + alpha**2.A**3 + ...) - A - alpha.A**2 - - alpha**2.A**3 - alpha**3.A**4 - ... - = I - And (I - alpha.A^{t})^{-1} = I + A + alpha.A**2 + alpha**2.A**3 + ... + $$c = [(I - \alpha A^{t})^{-1} - I]{\bf 1},$$ + where $A$ is the adjency matrix of the the (hyper)graph. + Since $A^{t} = A$ for undirected graphs (our case), we have : + $$(I + A + \alpha A^2 + \alpha^2 A^3 + ...)(I - \alpha A^{t})$$ + $$= (I + A + \alpha A^2 + \alpha^2 A^3 + ...) * (I - \alpha A)$$ + $$= (I + A + \alpha A^2 + \alpha^2 A^3 + ...) - A - \alpha A^2$$ + $$- \alpha^2 A^3 - alpha^3 A^4 - \dots$$ + $$= I$$ + And $(I - \alpha A^{t})^{-1} = I + A + \alpha A^2 + \alpha^2 A^3 + \dots$ Thus we can use the power serie to compute the Katz-centrality. [2] The Katz-centrality of isolated nodes (no hyperedges contains them) is zero. The Katz-centrality of an empty hypergraph is not defined. diff --git a/xgi/algorithms/clustering.py b/xgi/algorithms/clustering.py index a5e646e97..71591a5f2 100644 --- a/xgi/algorithms/clustering.py +++ b/xgi/algorithms/clustering.py @@ -1,3 +1,5 @@ +"""Algorithms for computing nodal clustering coefficients.""" + import numpy as np from ..exception import XGIError diff --git a/xgi/algorithms/shortest_path.py b/xgi/algorithms/shortest_path.py index 07ade3e4d..473c07755 100644 --- a/xgi/algorithms/shortest_path.py +++ b/xgi/algorithms/shortest_path.py @@ -1,6 +1,5 @@ """Algorithms for computing shortest paths in a hypergraph.""" - import numpy as np from ..utils import utilities @@ -19,8 +18,8 @@ def single_source_shortest_path_length(H, source): source : int Index of the node from which to compute the distance to every other node. - Return - ------ + Returns + ------- dists : dict Dictionary where keys are node indexes and values are the distances from source. """ @@ -86,13 +85,13 @@ def shortest_path_length(H): containing the distances from source to every other node in hypergraph H, for all possible source in H. - Parameter - --------- + Parameters + ---------- H : xgi.Hypergraph Hypergraph on which to compute the distances. Node indexes must be integers. - Return - ------ + Returns + ------- paths : generator of tuples Every tuple is of the form (source, dict_of_lengths), for every possible source. """ @@ -100,6 +99,3 @@ def shortest_path_length(H): for source in H.nodes: dists = single_source_shortest_path_length(H, source) yield (source, dists) - - -## to do : allow for nodes to have an id (dict, etc)... diff --git a/xgi/classes/__init__.py b/xgi/classes/__init__.py index a1a8fec96..90d71e484 100644 --- a/xgi/classes/__init__.py +++ b/xgi/classes/__init__.py @@ -1,4 +1,5 @@ from .hypergraph import Hypergraph +from .dihypergraph import DiHypergraph from .function import * from .hypergraphviews import subhypergraph from .simplicialcomplex import SimplicialComplex diff --git a/xgi/classes/dihypergraph.py b/xgi/classes/dihypergraph.py new file mode 100644 index 000000000..eab3d59cf --- /dev/null +++ b/xgi/classes/dihypergraph.py @@ -0,0 +1,836 @@ +"""Base class for directed hypergraphs. + +.. warning:: + This is currently an experimental feature. + +""" +from collections.abc import Hashable, Iterable +from copy import copy, deepcopy +from itertools import count +from warnings import warn + +from ..exception import XGIError +from ..utils import IDDict, update_uid_counter +from .direportviews import DiEdgeView, DiNodeView + +__all__ = ["DiHypergraph"] + + +class DiHypergraph: + r"""A dihypergraph is a collection of directed interactions of arbitrary size. + + .. warning:: + This is currently an experimental feature. + + More formally, a directed hypergraph (dihypergraph) is a pair :math:`(V, E)`, + where :math:`V` is a set of elements called *nodes* or *vertices*, + and :math:`E` is the set of directed hyperedges. + A directed hyperedge is an ordered pair, $(e^+, e^-)$, + where $e^+ \subset V$, the set of senders, is known as the "tail" and + $e^-\subset V$, the set of receivers, is known as the "head". + The equivalent undirected edge, is $e = e^+ \cap e^-$ and + the edge size is defined as $|e|$. + + The DiHypergraph class allows any hashable object as a node and can associate + attributes to each node, edge, or the hypergraph itself, in the form of key/value + pairs. + + Multiedges and self-loops are allowed. + + Parameters + ---------- + incoming_data : input directed hypergraph data (optional, default: None) + Data to initialize the dihypergraph. If None (default), an empty + hypergraph is created, i.e. one with no nodes or edges. + The data can be in the following formats: + + * directed hyperedge list + * directed hyperedge dictionary + * DiHypergraph object. + + **attr : dict, optional, default: None + Attributes to add to the hypergraph as key, value pairs. + + Notes + ----- + Unique IDs are assigned to each node and edge internally and are used to refer to + them throughout. + + The `attr` keyword arguments are added as hypergraph attributes. To add node or edge + attributes see :meth:`add_node` and :meth:`add_edge`. + + In addition to the methods listed in this page, other methods defined in the `stats` + package are also accessible via the `DiHypergraph` class. For more details, see the + `tutorial + `_. + + References + ---------- + Bretto, Alain. "Hypergraph theory: An introduction." + Mathematical Engineering. Cham: Springer (2013). + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([([1, 2, 3], [4]), ([5, 6], [6, 7, 8])]) + >>> H.nodes + DiNodeView((1, 2, 3, 4, 5, 6, 7, 8)) + >>> H.edges + DiEdgeView((0, 1)) + >>> [[sorted(h), sorted(t)] for h, t in H.edges.dimembers()] + [[[1, 2, 3], [4]], [[5, 6], [6, 7, 8]]] + >>> [sorted(e) for e in H.edges.members()] + [[1, 2, 3, 4], [5, 6, 7, 8]] + """ + _node_dict_factory = IDDict + _node_attr_dict_factory = IDDict + _hyperedge_dict_factory = IDDict + _hyperedge_attr_dict_factory = IDDict + _hypergraph_attr_dict_factory = dict + + def __getstate__(self): + """Function that allows pickling. + + Returns + ------- + dict + The keys label the hypergraph dict and the values + are dictionaries from the DiHypergraph class. + + Notes + ----- + This allows the python multiprocessing module to be used. + + """ + return { + "_edge_uid": self._edge_uid, + "_hypergraph": self._hypergraph, + "_node_in": self._node_in, + "_node_out": self._node_out, + "_node_attr": self._node_attr, + "_edge_in": self._edge_in, + "_edge_out": self._edge_out, + "_edge_attr": self._edge_attr, + } + + def __setstate__(self, state): + """Function that allows unpickling of a dihypergraph. + + Parameters + ---------- + state + The keys access the dictionary names the values are the + dictionarys themselves from the DiHypergraph class. + + Notes + ----- + This allows the python multiprocessing module to be used. + """ + self._edge_uid = state["_edge_uid"] + self._hypergraph = state["_hypergraph"] + self._node_in = state["_node_in"] + self._node_out = state["_node_out"] + self._node_attr = state["_node_attr"] + self._edge_in = state["_edge_in"] + self._edge_out = state["_edge_out"] + self._edge_attr = state["_edge_attr"] + self._nodeview = DiNodeView(self) + self._edgeview = DiEdgeView(self) + + def __init__(self, incoming_data=None, **attr): + self._edge_uid = count() + self._hypergraph = self._hypergraph_attr_dict_factory() + + self._node_in = self._node_dict_factory() + self._node_out = self._node_dict_factory() + self._node_attr = self._node_attr_dict_factory() + + self._edge_in = self._hyperedge_dict_factory() + self._edge_out = self._hyperedge_dict_factory() + self._edge_attr = self._hyperedge_attr_dict_factory() + + self._nodeview = DiNodeView(self) + """A :class:`~xgi.classes.direportviews.DiNodeView` of the directed hypergraph.""" + + self._edgeview = DiEdgeView(self) + """An :class:`~xgi.classes.direportviews.DiEdgeView` of the directed hypergraph.""" + + if incoming_data is not None: + # This import needs to happen when this function is called, not when it is + # defined. Otherwise, a circular import error would happen. + from ..convert import convert_to_dihypergraph + + convert_to_dihypergraph(incoming_data, create_using=self) + self._hypergraph.update(attr) # must be after convert + + def __str__(self): + """Returns a short summary of the directed hypergraph. + + Returns + ------- + string + DiHypergraph information + + """ + try: + return f"{type(self).__name__} named {self['name']} with {self.num_nodes} nodes and {self.num_edges} hyperedges" + except XGIError: + return f"Unnamed {type(self).__name__} with {self.num_nodes} nodes and {self.num_edges} hyperedges" + + def __iter__(self): + """Iterate over the nodes. + + Returns + ------- + iterator + An iterator over all nodes in the dihypergraph. + """ + return iter(self._node_in) + + def __contains__(self, n): + """Check for if a node is in this dihypergraph. + + Parameters + ---------- + n : hashable + node ID + + Returns + ------- + bool + Whether the node exists in the dihypergraph. + """ + try: + return n in self._node_in + except TypeError: + return False + + def __len__(self): + """Number of nodes in the dihypergraph. + + Returns + ------- + int + The number of nodes in the dihypergraph. + + See Also + -------- + num_nodes : identical method + num_edges : number of edges in the dihypergraph + + """ + return len(self._node_in) + + def __getitem__(self, attr): + """Read dihypergraph attribute.""" + try: + return self._hypergraph[attr] + except KeyError: + raise XGIError("This attribute has not been set.") + + def __setitem__(self, attr, val): + """Write dihypergraph attribute.""" + self._hypergraph[attr] = val + + def __getattr__(self, attr): + stat = getattr(self.nodes, attr, None) + word = "nodes" + if stat is None: + stat = getattr(self.edges, attr, None) + word = "edges" + if stat is None: + word = None + raise AttributeError( + f"{attr} is not a method of DiHypergraph or a recognized DiNodeStat or DiEdgeStat" + ) + + def func(node=None, *args, **kwargs): + val = stat(*args, **kwargs).asdict() + return val if node is None else val[node] + + func.__doc__ = f"""Equivalent to H.{word}.{attr}.asdict(). For accepted *args and + **kwargs, see documentation of H.{word}.{attr}.""" + + return func + + @property + def num_nodes(self): + """The number of nodes in the dihypergraph. + + Returns + ------- + int + The number of nodes in the dihypergraph. + + See Also + -------- + num_edges : returns the number of edges in the dihypergraph + + Examples + -------- + >>> import xgi + >>> hyperedge_list = [([1, 2], [2, 3, 4])] + >>> H = xgi.DiHypergraph(hyperedge_list) + >>> H.num_nodes + 4 + + """ + return len(self._node_in) + + @property + def num_edges(self): + """The number of directed edges in the dihypergraph. + + Returns + ------- + int + The number of directed edges in the dihypergraph. + + See Also + -------- + num_nodes : returns the number of nodes in the dihypergraph + + Examples + -------- + >>> import xgi + >>> hyperedge_list = [([1, 2], [2, 3, 4])] + >>> H = xgi.DiHypergraph(hyperedge_list) + >>> H.num_edges + 1 + """ + return len(self._edge_in) + + @property + def nodes(self): + """A :class:`DiNodeView` of this network.""" + return self._nodeview + + @property + def edges(self): + """An :class:`DiEdgeView` of this network.""" + return self._edgeview + + def add_node(self, node, **attr): + """Add one node with optional attributes. + + Parameters + ---------- + node : node + A node can be any hashable Python object except None. + attr : keyword arguments, optional + Set or change node attributes using key=value. + + See Also + -------- + add_nodes_from + + Notes + ----- + If node is already in the dihypergraph, its attributes are still updated. + + """ + if node not in self._node_in: + self._node_in[node] = set() + self._node_out[node] = set() + self._node_attr[node] = self._node_attr_dict_factory() + self._node_attr[node].update(attr) + + def add_nodes_from(self, nodes_for_adding, **attr): + """Add multiple nodes with optional attributes. + + Parameters + ---------- + nodes_for_adding : iterable + An iterable of nodes (list, dict, set, etc.). + OR + An iterable of (node, attribute dict) tuples. + Node attributes are updated using the attribute dict. + attr : keyword arguments, optional (default= no attributes) + Update attributes for all nodes in nodes. + Node attributes specified in nodes as a tuple take + precedence over attributes specified via keyword arguments. + + See Also + -------- + add_node + + """ + for n in nodes_for_adding: + try: + newnode = n not in self._node_in + newdict = attr + except TypeError: + n, ndict = n + newnode = n not in self._node_in + newdict = attr.copy() + newdict.update(ndict) + if newnode: + self._node_in[n] = set() + self._node_out[n] = set() + self._node_attr[n] = self._node_attr_dict_factory() + self._node_attr[n].update(newdict) + + def remove_node(self, n, strong=False): + """Remove a single node. + + The removal may be weak (default) or strong. In weak removal, the node is + removed from each of its containing edges. If it is contained in any singleton + edges, then these are also removed. In strong removal, all edges containing the + node are removed, regardless of size. + + Parameters + ---------- + n : node + A node in the dihypergraph + + strong : bool (default False) + Whether to execute weak or strong removal. + + Raises + ------ + XGIError + If n is not in the dihypergraph. + + See Also + -------- + remove_nodes_from + + """ + out_edge_neighbors = self._node_in[n] + in_edge_neighbors = self._node_out[n] + del self._node_in[n] + del self._node_out[n] + del self._node_attr[n] + + if strong: + for edge in in_edge_neighbors.union(out_edge_neighbors): + del self._edge_in[edge] + del self._edge_out[edge] + del self._edge_attr[edge] + else: # weak removal + for edge in in_edge_neighbors: + self._edge_in[edge].remove(n) + + for edge in out_edge_neighbors: + self._edge_out[edge].remove(n) + + # remove empty edges + for edge in in_edge_neighbors.union(out_edge_neighbors): + if not self._edge_in[edge] and not self._edge_out[edge]: + del self._edge_in[edge] + del self._edge_out[edge] + del self._edge_attr[edge] + + def remove_nodes_from(self, nodes): + """Remove multiple nodes. + + Parameters + ---------- + nodes : iterable + An iterable of nodes. + + See Also + -------- + remove_node + + """ + for n in nodes: + if n not in self._node_in: + warn(f"Node {n} not in dihypergraph") + continue + self.remove_node(n) + + def add_edge(self, members, id=None, **attr): + """Add one edge with optional attributes. + + Parameters + ---------- + members : Iterable + An list or tuple (size 2) of iterables. The first entry contains the + elements of the tail and the second entry contains the elements + of the head. + id : hashable, default None + Id of the new edge. If None, a unique numeric ID will be created. + **attr : dict, optional + Attributes of the new edge. + + Raises + ----- + XGIError + If `members` is empty or is not a list or tuple. + + See Also + -------- + add_edges_from : Add a collection of edges. + + Examples + -------- + + Add edges with or without specifying an edge id. + + >>> import xgi + >>> H = xgi.DiHypergraph() + >>> H.add_edge(([1, 2, 3], [2, 3, 4])) + >>> H.add_edge(([3, 4], set()), id='myedge') + """ + if not members: + raise XGIError("Cannot add an empty edge") + + if isinstance(members, (tuple, list)): + tail = members[0] + head = members[1] + else: + raise XGIError("Directed edge must be a list or tuple!") + + if not head and not tail: + raise XGIError("Cannot add an empty edge") + + uid = next(self._edge_uid) if id is None else id + + if id in self._edge_in.keys(): # check that uid is not present yet + warn(f"uid {id} already exists, cannot add edge {members}") + return + + self._edge_in[uid] = set() + self._edge_out[uid] = set() + + for node in tail: + if node not in self._node_in: + self._node_in[node] = set() + self._node_out[node] = set() + self._node_attr[node] = self._node_attr_dict_factory() + self._node_in[node].add(uid) + self._edge_out[uid].add(node) + + for node in head: + if node not in self._node_out: + self._node_in[node] = set() + self._node_out[node] = set() + self._node_attr[node] = self._node_attr_dict_factory() + self._node_out[node].add(uid) + self._edge_in[uid].add(node) + + self._edge_attr[uid] = self._hyperedge_attr_dict_factory() + self._edge_attr[uid].update(attr) + + if id: # set self._edge_uid correctly + update_uid_counter(self, id) + + def add_edges_from(self, ebunch_to_add, **attr): + """Add multiple directed edges with optional attributes. + + Parameters + ---------- + ebunch_to_add : Iterable + + Note that here, when we refer to an edge, as in the `add_edge` method, + it is a list or tuple (size 2) of iterables. The first entry contains the + elements of the tail and the second entry contains the elements + of the head. + + An iterable of edges. This may be an iterable of edges (Format 1), + where each edge is in the format described above. + + Alternatively, each element could also be a tuple in any of the following + formats: + + * Format 2: 2-tuple (edge, edge_id), or + * Format 4: 3-tuple (edge, edge_id, attr), + + where `edge` is in the format described above, `edge_id` is a hashable to use + as edge ID, and `attr` is a dict of attributes. Finally, `ebunch_to_add` + may be a dict of the form `{edge_id: edge_members}` (Format 5). + + Formats 2 and 3 are unambiguous because `attr` dicts are not hashable, while `id`s must be. + In Formats 2-4, each element of `ebunch_to_add` must have the same length, + i.e. you cannot mix different formats. The iterables containing edge + members cannot be strings. + + attr : \*\*kwargs, optional + Additional attributes to be assigned to all edges. Attribues specified via + `ebunch_to_add` take precedence over `attr`. + + See Also + -------- + add_edge : Add a single edge. + + Notes + ----- + Adding the same edge twice will create a multi-edge. Currently + cannot add empty edges; the method skips over them. + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph() + + When specifying edges by their members only, numeric edge IDs will be assigned + automatically. + + >>> H.add_edges_from([([0, 1], [1, 2]), ([2, 3, 4], [])]) + >>> H.edges.dimembers(dtype=dict) + {0: ({0, 1}, {1, 2}), 1: ({2, 3, 4}, set())} + + Custom edge ids can be specified using a dict. + + >>> H = xgi.DiHypergraph() + >>> H.add_edges_from({'one': ([0, 1], [1, 2]), 'two': ([2, 3, 4], [])}) + >>> H.edges.dimembers(dtype=dict) + {'one': ({0, 1}, {1, 2}), 'two': ({2, 3, 4}, set())} + + You can use the dict format to easily add edges from another hypergraph. + + >>> H2 = xgi.DiHypergraph() + >>> H2.add_edges_from(H.edges.dimembers(dtype=dict)) + >>> H.edges == H2.edges + True + + Alternatively, edge ids can be specified using an iterable of 2-tuples. + + >>> H = xgi.DiHypergraph() + >>> H.add_edges_from([(([0, 1], [1, 2]), 'one'), (([2, 3, 4], []), 'two')]) + >>> H.edges.dimembers(dtype=dict) + {'one': ({0, 1}, {1, 2}), 'two': ({2, 3, 4}, set())} + + Attributes for each edge may be specified using a 2-tuple for each edge. + Numeric IDs will be assigned automatically. + + >>> H = xgi.DiHypergraph() + >>> edges = [ + ... (([0, 1], [1, 2]), {'color': 'red'}), + ... (([2, 3, 4], []), {'color': 'blue', 'age': 40}), + ... ] + >>> H.add_edges_from(edges) + >>> {e: H.edges[e] for e in H.edges} + {0: {'color': 'red'}, 1: {'color': 'blue', 'age': 40}} + + Attributes and custom IDs may be specified using a 3-tuple for each edge. + + >>> H = xgi.DiHypergraph() + >>> edges = [ + ... (([0, 1], [1, 2]), 'one', {'color': 'red'}), + ... (([2, 3, 4], []), 'two', {'color': 'blue', 'age': 40}), + ... ] + >>> H.add_edges_from(edges) + >>> {e: H.edges[e] for e in H.edges} + {'one': {'color': 'red'}, 'two': {'color': 'blue', 'age': 40}} + + """ + # format 5 is the easiest one + if isinstance(ebunch_to_add, dict): + for id, members in ebunch_to_add.items(): + if id in self._edge_in.keys(): # check that uid is not present yet + warn(f"uid {id} already exists, cannot add edge {members}.") + continue + + if isinstance(members, (tuple, list)): + tail = members[0] + head = members[1] + else: + raise XGIError("Directed edge must be a list or tuple!") + + try: + self._edge_in[id] = set(head) + self._edge_out[id] = set(tail) + except TypeError as e: + raise XGIError("Invalid ebunch format") from e + + for n in tail: + if n not in self._node_in: + self._node_in[n] = set() + self._node_out[n] = set() + self._node_attr[n] = self._node_attr_dict_factory() + self._node_in[n].add(id) + self._edge_attr[id] = self._hyperedge_attr_dict_factory() + + for n in head: + if n not in self._node_in: + self._node_in[n] = set() + self._node_out[n] = set() + self._node_attr[n] = self._node_attr_dict_factory() + self._node_out[n].add(id) + + update_uid_counter(self, id) + + return + # in formats 1-4 we only know that ebunch_to_add is an iterable, so we iterate + # over it and use the firs element to determine which format we are working with + new_edges = iter(ebunch_to_add) + try: + first_edge = next(new_edges) + except StopIteration: + return + + second_elem = list(first_edge)[1] + + format1, format2, format3, format4 = False, False, False, False + + if ( + isinstance(second_elem, Iterable) + and not isinstance(second_elem, str) + and not isinstance(second_elem, dict) + ): + format1 = True + else: + if len(first_edge) == 3: + format4 = True + elif len(first_edge) == 2 and issubclass(type(first_edge[1]), Hashable): + format2 = True + elif len(first_edge) == 2: + format3 = True + + # now we may iterate over the rest + e = first_edge + while True: + if format1: + members, id, eattr = e, next(self._edge_uid), {} + elif format2: + members, id, eattr = e[0], e[1], {} + elif format3: + members, id, eattr = e[0], next(self._edge_uid), e[1] + elif format4: + members, id, eattr = e[0], e[1], e[2] + + if id in self._edge_in.keys(): # check that uid is not present yet + warn(f"uid {id} already exists, cannot add edge {members}.") + else: + try: + tail = members[0] + head = members[1] + self._edge_out[id] = set(tail) + self._edge_in[id] = set(head) + except TypeError as e: + raise XGIError("Invalid ebunch format") from e + + for node in tail: + if node not in self._node_in: + self._node_in[node] = set() + self._node_out[node] = set() + self._node_attr[node] = self._node_attr_dict_factory() + self._node_in[node].add(id) + self._edge_out[id].add(node) + + for node in head: + if node not in self._node_out: + self._node_in[node] = set() + self._node_out[node] = set() + self._node_attr[node] = self._node_attr_dict_factory() + self._node_out[node].add(id) + self._edge_in[id].add(node) + + self._edge_attr[id] = self._hyperedge_attr_dict_factory() + self._edge_attr[id].update(attr) + self._edge_attr[id].update(eattr) + + try: + e = next(new_edges) + except StopIteration: + if format2 or format4: + update_uid_counter(self, id) + break + + def remove_edge(self, id): + """Remove one edge. + + Parameters + ---------- + id : Hashable + edge ID to remove + + Raises + ------ + XGIError + If no edge has that ID. + + See Also + -------- + remove_edges_from : Remove multiple edges. + + """ + head = self._edge_in[id].copy() + tail = self._edge_out[id].copy() + + for node in head: + self._node_out[node].remove(id) + for node in tail: + self._node_in[node].remove(id) + + del self._edge_in[id] + del self._edge_out[id] + del self._edge_attr[id] + + def remove_edges_from(self, ebunch): + """Remove multiple edges. + + Parameters + ---------- + ebunch: Iterable + Edges to remove. + + Raises + ------ + xgi.exception.IDNotFound + If an id in ebunch is not part of the network. + + See Also + -------- + remove_edge : remove a single edge. + + """ + for id in ebunch: + head = self._edge_in[id].copy() + tail = self._edge_out[id].copy() + + for node in head: + self._node_out[node].remove(id) + for node in tail: + self._node_in[node].remove(id) + + del self._edge_in[id] + del self._edge_out[id] + del self._edge_attr[id] + + def clear(self, hypergraph_attr=True): + """Remove all nodes and edges from the graph. + + Also removes node and edge attributes, and optionally hypergraph attributes. + + Parameters + ---------- + hypergraph_attr : bool, optional + Whether to remove hypergraph attributes as well. + By default, True. + + """ + self._node_in.clear() + self._node_out.clear() + self._node_attr.clear() + self._edge_in.clear() + self._edge_out.clear() + self._edge_attr.clear() + if hypergraph_attr: + self._hypergraph.clear() + + def copy(self): + """A deep copy of the dihypergraph. + + A deep copy of the dihypergraph, including node, edge, and hypergraph attributes. + + Returns + ------- + H : DiHypergraph + A copy of the hypergraph. + + """ + cp = self.__class__() + nn = self.nodes + cp.add_nodes_from((n, deepcopy(attr)) for n, attr in nn.items()) + ee = self.edges + cp.add_edges_from( + (e, id, deepcopy(self.edges[id])) + for id, e in ee.dimembers(dtype=dict).items() + ) + cp._hypergraph = deepcopy(self._hypergraph) + + cp._edge_uid = copy(self._edge_uid) + + return cp diff --git a/xgi/classes/direportviews.py b/xgi/classes/direportviews.py new file mode 100644 index 000000000..423b08544 --- /dev/null +++ b/xgi/classes/direportviews.py @@ -0,0 +1,725 @@ +"""View classes for dihypergraphs. + +A View class allows for inspection and querying of an underlying object but does not +allow modification. This module provides View classes for nodes and edges of a dihypergraph. +Views are automatically updaed when the dihypergraph changes. + +""" + +from collections.abc import Mapping, Set + +from ..exception import IDNotFound, XGIError +from ..stats import IDStat, dispatch_many_stats, dispatch_stat + +__all__ = [ + "DiNodeView", + "DiEdgeView", +] + + +class DiIDView(Mapping, Set): + """Base View class for accessing the ids (nodes or edges) of a DiHypergraph. + + Can optionally keep track of a subset of ids. By default all node ids or all edge + ids are kept track of. + + Parameters + ---------- + id_dict : dict + The original dict this is a view of. + id_attrs : dict + The original attribute dict this is a view of. + ids : iterable + A subset of the keys in id_dict to keep track of. + + Raises + ------ + XGIError + If ids is not a subset of the keys of id_dict. + + """ + + _id_kind = None + + __slots__ = ( + "_net", + "_ids", + ) + + def __getstate__(self): + """Function that allows pickling. + + Returns + ------- + dict + The keys access the IDs and their attributes respectively + and the values are dictionarys from the Hypergraph class. + + """ + return { + "_net": self._net, + "_ids": self._ids, + } + + def __setstate__(self, state): + """Function that allows unpickling. + + Parameters + ---------- + dict + The keys access the IDs and their attributes respectively + and the values are dictionarys from the Hypergraph class. + + """ + self._net = state["_net"] + self._id_kind = state["_id_kind"] + + def __init__(self, network, ids=None): + self._net = network + + if self._id_kind == "dinode": + self._in_id_dict = None if self._net is None else network._node_in + self._out_id_dict = None if self._net is None else network._node_out + self._id_attr = None if self._net is None else network._node_attr + self._bi_in_id_dict = None if self._net is None else network._edge_in + self._bi_out_id_dict = None if self._net is None else network._edge_out + self._bi_id_attr = None if self._net is None else network._edge_attr + elif self._id_kind == "diedge": + self._in_id_dict = None if self._net is None else network._edge_in + self._out_id_dict = None if self._net is None else network._edge_out + self._id_attr = None if self._net is None else network._edge_attr + self._bi_in_id_dict = None if self._net is None else network._node_in + self._bi_out_id_dict = None if self._net is None else network._node_out + self._bi_id_attr = None if self._net is None else network._node_attr + + if ids is None: + self._ids = self._in_id_dict + else: + self._ids = ids + + def __getattr__(self, attr): + stat = dispatch_stat(self._id_kind, self._net, self, attr) + self.__dict__[attr] = stat + return stat + + def multi(self, names): + return dispatch_many_stats(self._id_kind, self._net, self, names) + + @property + def ids(self): + """The ids in this view. + + Notes + ----- + Do not use this property for membership check. Instead of `x in view.ids`, + always use `x in view`. The latter is always faster. + + """ + return set(self._in_id_dict) if self._ids is None else self._ids + + def __len__(self): + """The number of IDs.""" + return len(self._in_id_dict) if self._ids is None else len(self._ids) + + def __iter__(self): + """Returns an iterator over the IDs.""" + if self._ids is None: + return iter({}) if self._in_id_dict is None else iter(self._in_id_dict) + else: + return iter(self._ids) + + def __getitem__(self, id): + """Get the attributes of the ID. + + Parameters + ---------- + id : hashable + node or edge ID + + Returns + ------- + dict + Node attributes. + + Raises + ------ + XGIError + If the id is not being kept track of by this view, or if id is not in the + hypergraph, or if id is not hashable. + + """ + if id not in self: + raise IDNotFound(f"The ID {id} is not in this view") + return self._id_attr[id] + + def __contains__(self, id): + """Checks whether the ID is in the dihypergraph""" + return id in self._ids + + def __str__(self): + """Returns a string of the list of IDs.""" + return str(list(self)) + + def __repr__(self): + """Returns a summary of the class""" + return f"{self.__class__.__name__}({tuple(self)})" + + def __call__(self, bunch): + """Filter to the given bunch. + + Parameters + ---------- + bunch : Iterable + Iterable of IDs + + Returns + ------- + IDView + A new view that keeps track only of the IDs in the bunch. + + """ + return self.from_view(self, bunch) + + def filterby(self, stat, val, mode="eq"): + """Filter the IDs in this view by a statistic. + + Parameters + ---------- + stat : str or :class:`xgi.stats.DiNodeStat`/:class:`xgi.stats.DiEdgeStat` + `DiNodeStat`/`DiEdgeStat` object, or name of a `DiNodeStat`/`DiEdgeStat`. + val : Any + Value of the statistic. Usually a single numeric value. When mode is + 'between', must be a tuple of exactly two values. + mode : str, optional + How to compare each value to `val`. Can be one of the following. + + * 'eq' (default): Return IDs whose value is exactly equal to `val`. + * 'neq': Return IDs whose value is not equal to `val`. + * 'lt': Return IDs whose value is less than `val`. + * 'gt': Return IDs whose value is greater than `val`. + * 'leq': Return IDs whose value is less than or equal to `val`. + * 'geq': Return IDs whose value is greater than or equal to `val`. + * 'between': In this mode, `val` must be a tuple `(val1, val2)`. Return IDs + whose value `v` satisfies `val1 <= v <= val2`. + + See Also + -------- + IDView.filterby_attr : For more details, see the `tutorial + `_. + + Examples + -------- + By default, return the IDs whose value of the statistic is exactly equal to + `val`. + + >>> import xgi + >>> H = xgi.DiHypergraph([([1, 2, 3], [2, 3, 4, 5]), ([3, 4, 5], [])]) + >>> n = H.nodes + >>> n.filterby('degree', 2) + DiNodeView((3, 4, 5)) + + Can choose other comparison methods via `mode`. + + >>> n.filterby('degree', 2, 'eq') + DiNodeView((3, 4, 5)) + >>> n.filterby('degree', 2, 'neq') + DiNodeView((1, 2)) + >>> n.filterby('degree', 2, 'lt') + DiNodeView((1, 2)) + >>> n.filterby('degree', 2, 'gt') + DiNodeView(()) + >>> n.filterby('degree', 2, 'leq') + DiNodeView((1, 2, 3, 4, 5)) + >>> n.filterby('degree', 2, 'geq') + DiNodeView((3, 4, 5)) + >>> n.filterby('degree', (2, 3), 'between') + DiNodeView((3, 4, 5)) + """ + if not isinstance(stat, IDStat): + try: + stat = getattr(self, stat) + except AttributeError as e: + raise AttributeError(f'Statistic with name "{stat}" not found') from e + + values = stat.asdict() + if mode == "eq": + bunch = [idx for idx in self if values[idx] == val] + elif mode == "neq": + bunch = [idx for idx in self if values[idx] != val] + elif mode == "lt": + bunch = [idx for idx in self if values[idx] < val] + elif mode == "gt": + bunch = [idx for idx in self if values[idx] > val] + elif mode == "leq": + bunch = [idx for idx in self if values[idx] <= val] + elif mode == "geq": + bunch = [idx for idx in self if values[idx] >= val] + elif mode == "between": + bunch = [node for node in self if val[0] <= values[node] <= val[1]] + else: + raise ValueError( + f"Unrecognized mode {mode}. mode must be one of 'eq', 'neq', 'lt', 'gt', 'leq', 'geq', or 'between'." + ) + return type(self).from_view(self, bunch) + + def filterby_attr(self, attr, val, mode="eq", missing=None): + """Filter the IDs in this view by an attribute. + + Parameters + ---------- + attr : string + The name of the attribute + val : Any + A single value or, in the case of 'between', a list of length 2 + mode : str, optional + Comparison mode. Valid options are 'eq' (default), 'neq', 'lt', 'gt', + 'leq', 'geq', or 'between'. + missing : Any, optional + The default value if the attribute is missing. If None (default), + ignores those IDs. + + + See Also + -------- + DiIDView.filterby : Identical method. For more details, see the `tutorial + `_. + + Notes + ----- + Beware of using comparison modes ("lt", "gt", "leq", "geq") + when the attribute is a string. For example, the string comparison + `'10' < '9'` evaluates to `True`. + """ + attrs = dispatch_stat(self._id_kind, self._net, self, "attrs") + values = attrs(attr, missing).asdict() + + if mode == "eq": + bunch = [ + idx for idx in self if values[idx] is not None and values[idx] == val + ] + elif mode == "neq": + bunch = [ + idx for idx in self if values[idx] is not None and values[idx] != val + ] + elif mode == "lt": + bunch = [ + idx for idx in self if values[idx] is not None and values[idx] < val + ] + elif mode == "gt": + bunch = [ + idx for idx in self if values[idx] is not None and values[idx] > val + ] + elif mode == "leq": + bunch = [ + idx for idx in self if values[idx] is not None and values[idx] <= val + ] + elif mode == "geq": + bunch = [ + idx for idx in self if values[idx] is not None and values[idx] >= val + ] + elif mode == "between": + bunch = [ + idx + for idx in self + if values[idx] is not None and val[0] <= values[idx] <= val[1] + ] + else: + raise ValueError( + f"Unrecognized mode {mode}. mode must be one of 'eq', 'neq', 'lt', 'gt', 'leq', 'geq', or 'between'." + ) + return type(self).from_view(self, bunch) + + @classmethod + def from_view(cls, view, bunch=None): + """Create a view from another view. + + Allows to create a view with the same underlying data but with a different + bunch. + + Parameters + ---------- + view : IDView + The view used to initialze the new object + bunch : iterable + IDs the new view will keep track of + + Returns + ------- + DiIDView + A view that is identical to `view` but keeps track of different IDs. + + """ + newview = cls(None) + newview._net = view._net + newview._in_id_dict = view._in_id_dict + newview._out_id_dict = view._out_id_dict + newview._id_attr = view._id_attr + newview._bi_in_id_dict = view._bi_in_id_dict + newview._bi_out_id_dict = view._bi_out_id_dict + newview._bi_id_attr = view._bi_id_attr + all_ids = set(view._in_id_dict) + if bunch is None: + newview._ids = all_ids + else: + bunch = set(bunch) + wrong = bunch - all_ids + if wrong: + raise IDNotFound(f"IDs {wrong} not in the hypergraph") + newview._ids = bunch + return newview + + def _from_iterable(self, it): + """Construct an instance of the class from any iterable input. + + This overrides collections.abc.Set._from_iterable, which is in turn used to + implement set operations such as &, |, ^, -. + + """ + return self.from_view(self, it) + + +class DiNodeView(DiIDView): + """An DiIDView that keeps track of node ids. + + .. warning:: + This is currently an experimental feature. + + Parameters + ---------- + hypergraph : DiHypergraph + The hypergraph whose nodes this view will keep track of. + bunch : optional iterable, default None + The node ids to keep track of. If None (default), keep track of all node ids. + + See Also + -------- + DiIDView + + Notes + ----- + In addition to the methods listed in this page, other methods defined in the `stats` + package are also accessible via the `NodeView` class. For more details, see the + `tutorial + `_. + + """ + + _id_kind = "dinode" + + def __init__(self, H, bunch=None): + if H is None: + super().__init__(None, bunch) + else: + super().__init__(H, bunch) + + def dimemberships(self, n=None): + """Get the edge ids of which a node is a member. + + Gets all the node memberships for all nodes in the view if n + not specified. + + Parameters + ---------- + n : hashable, optional + Node ID. By default, None. + + Returns + ------- + dict of directed node memberships if n is None, + otherwise the directed memberships of a single node. + + Raises + ------ + XGIError + If `n` is not hashable or if it is not in the hypergraph. + + """ + return ( + { + key: (self._out_id_dict[key].copy(), self._in_id_dict[key].copy()) + for key in self + } + if n is None + else (self._out_id_dict[n].copy(), self._in_id_dict[n].copy()) + ) + + def memberships(self, n=None): + """Get the edge ids of which a node is a member. + + Gets all the node memberships for all nodes in the view if n + not specified. + + Parameters + ---------- + n : hashable, optional + Node ID. By default, None. + + Returns + ------- + dict of sets if n is None, otherwise a set + Node memberships, regardless of whether + that node is a sender or receiver. + + Raises + ------ + XGIError + If `n` is not hashable or if it is not in the dihypergraph. + + """ + return ( + { + key: set(self._out_id_dict[key].union(self._in_id_dict[key])) + for key in self + } + if n is None + else set(self._out_id_dict[n].union(self._in_id_dict[n])) + ) + + +class DiEdgeView(DiIDView): + """An DiIDView that keeps track of edge ids. + + .. warning:: + This is currently an experimental feature. + + Parameters + ---------- + hypergraph : DiHypergraph + The hypergraph whose edges this view will keep track of. + bunch : optional iterable, default None + The edge ids to keep track of. If None (default), keep track of all edge ids. + + See Also + -------- + DiIDView + + Notes + ----- + In addition to the methods listed in this page, other methods defined in the `stats` + package are also accessible via the `EdgeView` class. For more details, see the + `tutorial + `_. + + """ + + _id_kind = "diedge" + + def __init__(self, H, bunch=None): + if H is None: + super().__init__(None, bunch) + else: + super().__init__(H, bunch) + + def dimembers(self, e=None, dtype=list): + """Get the node ids that are members of an edge. + + Parameters + ---------- + e : hashable, optional + Edge ID. By default, None. + dtype : {list, dict}, optional + Specify the type of the return value. + By default, list. + + Returns + ------- + list (if dtype is list, default) + Directed edges. + dict (if dtype is dict) + Directed edges. + set (if e is not None) + A single directed edge. + + In all of these cases, a directed edge is + a 2-tuple of sets, where the first entry + is the tail, and the second entry is the head. + + Raises + ------ + TypeError + If `e` is not None or a hashable + XGIError + If `dtype` is not dict or list + IDNotFound + If `e` does not exist in the hypergraph + + """ + if e is None: + if dtype is dict: + return { + key: (self._out_id_dict[key].copy(), self._in_id_dict[key].copy()) + for key in self + } + elif dtype is list: + return [ + (self._out_id_dict[key].copy(), self._in_id_dict[key].copy()) + for key in self + ] + else: + raise XGIError(f"Unrecognized dtype {dtype}") + + if e not in self: + raise IDNotFound(f'ID "{e}" not in this view') + + return (self._out_id_dict[e].copy(), self._in_id_dict[e].copy()) + + def members(self, e=None, dtype=list): + """Get the edges of a directed hypergraph. + + Parameters + ---------- + e : hashable, optional + Edge ID. By default, None. + dtype : {list, dict}, optional + Specify the type of the return value. + By default, list. + + Returns + ------- + list (if dtype is list, default) + Edge members. + dict (if dtype is dict) + Edge members. + set (if e is not None) + Members of edge e. + + The members of an edge are the union of + its head and tail sets. + + The + + Raises + ------ + TypeError + If `e` is not None or a hashable + XGIError + If `dtype` is not dict or list + IDNotFound + If `e` does not exist in the hypergraph + + """ + if e is None: + if dtype is dict: + return { + key: set(self._out_id_dict[key].union(self._in_id_dict[key])) + for key in self + } + elif dtype is list: + return [ + set(self._out_id_dict[key].union(self._in_id_dict[key])) + for key in self + ] + else: + raise XGIError(f"Unrecognized dtype {dtype}") + + if e not in self: + raise IDNotFound(f'ID "{e}" not in this view') + + return set(self._out_id_dict[e].union(self._in_id_dict[e])) + + def head(self, e=None, dtype=list): + """Get the node ids that are in the head of a directed edge. + + Parameters + ---------- + e : hashable, optional + Edge ID. By default, None. + dtype : {list, dict}, optional + Specify the type of the return value. + By default, list. + + Returns + ------- + list (if dtype is list, default) + Head members. + dict (if dtype is dict) + Head members. + set (if e is not None) + Members of the head of edge e. + + Raises + ------ + TypeError + If `e` is not None or a hashable + XGIError + If `dtype` is not dict or list + IDNotFound + If `e` does not exist in the hypergraph + + """ + if e is None: + if dtype is dict: + return {key: self._in_id_dict[key].copy() for key in self} + elif dtype is list: + return [self._in_id_dict[key].copy() for key in self] + else: + raise XGIError(f"Unrecognized dtype {dtype}") + + if e not in self: + raise IDNotFound(f'ID "{e}" not in this view') + + return self._in_id_dict[e].copy() + + def tail(self, e=None, dtype=list): + """Get the node ids that are in the tail of a directed edge. + + Parameters + ---------- + e : hashable, optional + Edge ID. By default, None. + dtype : {list, dict}, optional + Specify the type of the return value. + By default, list. + + Returns + ------- + list (if dtype is list, default) + Tail members. + dict (if dtype is dict) + Tail members. + set (if e is not None) + Tail members of edge e. + + Raises + ------ + TypeError + If `e` is not None or a hashable + XGIError + If `dtype` is not dict or list + IDNotFound + If `e` does not exist in the hypergraph + + """ + if e is None: + if dtype is dict: + return {key: self._out_id_dict[key].copy() for key in self} + elif dtype is list: + return [self._out_id_dict[key].copy() for key in self] + else: + raise XGIError(f"Unrecognized dtype {dtype}") + + if e not in self: + raise IDNotFound(f'ID "{e}" not in this view') + + return self._out_id_dict[e].copy() + + def sources(self, e=None, dtype=list): + """Get the nodes that are sources (senders) + in the directed edges. + + See Also + -------- + tail: identical method + """ + return self.tail(e=e, dtype=dtype) + + def targets(self, e=None, dtype=list): + """Get the nodes that are sources (senders) + in the directed edges. + + See Also + -------- + head: identical method + + """ + return self.head(e=e, dtype=dtype) diff --git a/xgi/classes/hypergraph.py b/xgi/classes/hypergraph.py index e136e2ab1..65fa009ed 100644 --- a/xgi/classes/hypergraph.py +++ b/xgi/classes/hypergraph.py @@ -22,9 +22,7 @@ class Hypergraph: The Hypergraph class allows any hashable object as a node and can associate attributes to each node, edge, or the hypergraph itself, in the form of key/value - pairs. - - Multiedges and self-loops are allowed. + pairs. In this representation, multiedges are allowed. Parameters ---------- @@ -898,7 +896,7 @@ def remove_edge(self, id): remove_edges_from : Remove multiple edges. """ - for node in self.edges.members(id): + for node in self._edge[id].copy(): self._node[node].remove(id) del self._edge[id] del self._edge_attr[id] @@ -922,7 +920,7 @@ def remove_edges_from(self, ebunch): """ for id in ebunch: - for node in self.edges.members(id): + for node in self._edge[id].copy(): self._node[node].remove(id) del self._edge[id] del self._edge_attr[id] @@ -996,7 +994,7 @@ def update(self, *, edges=None, nodes=None): def clear(self, hypergraph_attr=True): """Remove all nodes and edges from the graph. - Also removes node and edge attribues, and optionally hypergraph attributes. + Also removes node and edge attributes, and optionally hypergraph attributes. Parameters ---------- diff --git a/xgi/classes/reportviews.py b/xgi/classes/reportviews.py index 225b8a18d..eca433ecd 100644 --- a/xgi/classes/reportviews.py +++ b/xgi/classes/reportviews.py @@ -1,8 +1,8 @@ """View classes for hypergraphs. A View class allows for inspection and querying of an underlying object but does not -allow modification. This module provides View classes for nodes, edges, degree, and -edge size of a hypergraph. Views are automatically updaed when the hypergraph changes. +allow modification. This module provides View classes for nodes and edges of a hypergraph. +Views are automatically updaed when the hypergraph changes. """ @@ -181,7 +181,7 @@ def filterby(self, stat, val, mode="eq"): Parameters ---------- - stat : str or :class:`xgi.stats.NodeStat`/`xgi.stats.EdgeStat` + stat : str or :class:`xgi.stats.NodeStat`/:class:`xgi.stats.EdgeStat` `NodeStat`/`EdgeStat` object, or name of a `NodeStat`/`EdgeStat`. val : Any Value of the statistic. Usually a single numeric value. When mode is diff --git a/xgi/convert.py b/xgi/convert.py index 074e31c57..37806e2f5 100644 --- a/xgi/convert.py +++ b/xgi/convert.py @@ -17,14 +17,14 @@ lil_matrix, ) -from .classes import Hypergraph, SimplicialComplex, set_edge_attributes +from .classes import DiHypergraph, Hypergraph, SimplicialComplex, set_edge_attributes from .exception import XGIError -from .generators import empty_hypergraph, empty_simplicial_complex +from .generators import empty_dihypergraph, empty_hypergraph, empty_simplicial_complex from .linalg import adjacency_matrix, incidence_matrix -from .utils.utilities import dual_dict __all__ = [ "convert_to_hypergraph", + "convert_to_dihypergraph", "convert_to_graph", "convert_to_simplicial_complex", "from_hyperedge_list", @@ -81,6 +81,15 @@ def convert_to_hypergraph(data, create_using=None): H._hypergraph = deepcopy(data._hypergraph) return H + elif isinstance(data, DiHypergraph): + H = empty_hypergraph(create_using) + H.add_nodes_from((n, attr) for n, attr in data.nodes.items()) + ee = data.edges + H.add_edges_from((ee.members(e), e, deepcopy(attr)) for e, attr in ee.items()) + H._hypergraph = deepcopy(data._hypergraph) + if not isinstance(create_using, DiHypergraph): + return H + elif isinstance(data, SimplicialComplex): return from_max_simplices(data) @@ -122,6 +131,60 @@ def convert_to_hypergraph(data, create_using=None): raise XGIError("Input data has unsupported type.") +def convert_to_dihypergraph(data, create_using=None): + """Make a dihypergraph from a known data structure. + + The preferred way to call this is automatically from the class constructor. + + Parameters + ---------- + data : object to be converted + Current known types are: + * a DiHypergraph object + * a SimplicialComplex object + * list-of-iterables + * dict-of-iterables + * Pandas DataFrame (bipartite edgelist) + * numpy matrix + * numpy ndarray + * scipy sparse matrix + create_using : Hypergraph constructor, optional (default=Hypergraph) + Hypergraph type to create. If hypergraph instance, then cleared before populated. + + Returns + ------- + Hypergraph object + A hypergraph constructed from the data + + """ + if data is None: + return empty_dihypergraph(create_using) + + elif isinstance(data, DiHypergraph): + H = empty_dihypergraph(create_using) + H.add_nodes_from((n, attr) for n, attr in data.nodes.items()) + ee = data.edges + H.add_edges_from((ee.dimembers(e), e, deepcopy(attr)) for e, attr in ee.items()) + H._hypergraph = deepcopy(data._hypergraph) + if not isinstance(create_using, DiHypergraph): + return H + + elif isinstance(data, list): + # edge list + result = from_hyperedge_list(data, create_using) + if not isinstance(create_using, DiHypergraph): + return result + + elif isinstance(data, dict): + # edge dict in the form we need + result = from_hyperedge_dict(data, create_using) + if not isinstance(create_using, DiHypergraph): + return result + + else: + raise XGIError("Input data has unsupported type.") + + def convert_to_graph(H): """Graph projection (1-skeleton) of the hypergraph H. Weights are not considered. @@ -339,7 +402,6 @@ def from_hyperedge_dict(d, create_using=None): """ H = empty_hypergraph(create_using) - H.add_nodes_from(dual_dict(d)) H.add_edges_from((members, uid) for uid, members in d.items()) return H diff --git a/xgi/generators/classic.py b/xgi/generators/classic.py index 39a88d832..89b168384 100644 --- a/xgi/generators/classic.py +++ b/xgi/generators/classic.py @@ -9,6 +9,7 @@ __all__ = [ "empty_hypergraph", + "empty_dihypergraph", "empty_simplicial_complex", "trivial_hypergraph", "complete_hypergraph", @@ -26,7 +27,7 @@ def _empty_network(create_using, default): """ if create_using is None: H = default() - elif hasattr(create_using, "_node"): + elif hasattr(create_using, "_node") or hasattr(create_using, "_node_in"): # create_using is a Hypergraph object create_using.clear() H = create_using @@ -75,6 +76,45 @@ def empty_hypergraph(create_using=None, default=None): return _empty_network(create_using, default) +def empty_dihypergraph(create_using=None, default=None): + """Returns the empty dihypergraph with zero nodes and edges. + + Parameters + ---------- + create_using : DiHypergraph Instance, Constructor or None + If None, use the `default` constructor. + If a constructor, call it to create an empty dihypergraph. + default : DiHypergraph constructor (default None) + The constructor to use if create_using is None. + If None, then xgi.Hypergraph is used. + + Returns + ------- + Hypergraph object + An empty hypergraph + + See also + -------- + empty_simplicial_complex + trivial_hypergraph + + Examples + -------- + >>> import xgi + >>> H = xgi.empty_dihypergraph() + >>> H.num_nodes, H.num_edges + (0, 0) + + """ + # this import needs to happen when the function runs, not when the module is first + # imported, to avoid circular imports + import xgi + + if default is None: + default = xgi.DiHypergraph + return _empty_network(create_using, default) + + def empty_simplicial_complex(create_using=None, default=None): """Returns the empty simplicial complex with zero nodes and simplices. diff --git a/xgi/stats/__init__.py b/xgi/stats/__init__.py index 925939680..b9a377737 100644 --- a/xgi/stats/__init__.py +++ b/xgi/stats/__init__.py @@ -50,9 +50,16 @@ from xgi.exception import IDNotFound -from . import edgestats, nodestats +from . import edgestats, diedgestats, dinodestats, nodestats -__all__ = ["nodestat_func", "edgestat_func", "dispatch_stat", "dispatch_many_stats"] +__all__ = [ + "nodestat_func", + "edgestat_func", + "dinodestat_func", + "diedgestat_func", + "dispatch_stat", + "dispatch_many_stats", +] class IDStat: @@ -208,6 +215,16 @@ class NodeStat(IDStat): """ +class DiNodeStat(IDStat): + """An arbitrary node-quantity mapping. + + `NodeStat` objects represent a mapping that assigns a value to each node in a + network. For more details, see the `tutorial + `_. + + """ + + class EdgeStat(IDStat): """An arbitrary edge-quantity mapping. @@ -218,6 +235,16 @@ class EdgeStat(IDStat): """ +class DiEdgeStat(IDStat): + """An arbitrary edge-quantity mapping. + + `EdgeStat` objects represent a mapping that assigns a value to each edge in a + network. For more details, see the `tutorial + `_. + + """ + + class MultiIDStat(IDStat): """Multiple mappings.""" @@ -400,6 +427,18 @@ class MultiNodeStat(MultiIDStat): statsmodule = nodestats +class MultiDiNodeStat(MultiIDStat): + """Multiple node-quantity mappings. + + For more details, see the `tutorial + `_. + + """ + + statsclass = DiNodeStat + statsmodule = dinodestats + + class MultiEdgeStat(MultiIDStat): """Multiple edge-quantity mappings. @@ -412,17 +451,39 @@ class MultiEdgeStat(MultiIDStat): statsmodule = edgestats +class MultiDiEdgeStat(MultiIDStat): + """Multiple edge-quantity mappings. + + For more details, see the `tutorial + `_. + + """ + + statsclass = DiEdgeStat + statsmodule = diedgestats + + _dispatch_data = { "node": { "module": nodestats, "statclass": NodeStat, "multistatclass": MultiNodeStat, }, + "dinode": { + "module": dinodestats, + "statclass": DiNodeStat, + "multistatclass": MultiDiNodeStat, + }, "edge": { "module": edgestats, "statclass": EdgeStat, "multistatclass": MultiEdgeStat, }, + "diedge": { + "module": diedgestats, + "statclass": DiEdgeStat, + "multistatclass": MultiDiEdgeStat, + }, } @@ -533,6 +594,66 @@ def nodestat_func(func): return func +def dinodestat_func(func): + """Decorator that allows arbitrary functions to behave like :class:`DiNodeStat` objects. + + Works identically to :func:`nodestat`. For extended documentation, see + :func:`nodestat_func`. + + Parameters + ---------- + func : callable + Function or callable with signature `func(net, bunch)`, where `net` is the + network and `bunch` is an iterable of edges in `net`. The call `func(net, + bunch)` must return a dict with pairs of the form `(edge: value)` where `edge` + is in `bunch` and `value` is the value of the statistic at `edge`. + + Returns + ------- + callable + The decorated callable unmodified, after registering it in the `stats` framework. + + See Also + -------- + :func:`nodestat_func` + :func:`edgestat_func` + :func:`diedgestat_func` + + """ + setattr(dinodestats, func.__name__, func) + return func + + +def edgestat_func(func): + """Decorator that allows arbitrary functions to behave like :class:`EdgeStat` objects. + + Works identically to :func:`nodestat`. For extended documentation, see + :func:`nodestat_func`. + + Parameters + ---------- + func : callable + Function or callable with signature `func(net, bunch)`, where `net` is the + network and `bunch` is an iterable of edges in `net`. The call `func(net, + bunch)` must return a dict with pairs of the form `(edge: value)` where `edge` + is in `bunch` and `value` is the value of the statistic at `edge`. + + Returns + ------- + callable + The decorated callable unmodified, after registering it in the `stats` framework. + + See Also + -------- + :func:`nodestat_func` + :func:`edgestat_func` + :func:`diedgestat_func` + + """ + setattr(dinodestats, func.__name__, func) + return func + + def edgestat_func(func): """Decorate arbitrary functions to behave like :class:`EdgeStat` objects. @@ -560,3 +681,33 @@ def edgestat_func(func): """ setattr(edgestats, func.__name__, func) return func + + +def diedgestat_func(func): + """Decorator that allows arbitrary functions to behave like :class:`DiEdgeStat` objects. + + Works identically to :func:`nodestat`. For extended documentation, see + :func:`nodestat_func`. + + Parameters + ---------- + func : callable + Function or callable with signature `func(net, bunch)`, where `net` is the + network and `bunch` is an iterable of edges in `net`. The call `func(net, + bunch)` must return a dict with pairs of the form `(edge: value)` where `edge` + is in `bunch` and `value` is the value of the statistic at `edge`. + + Returns + ------- + callable + The decorated callable unmodified, after registering it in the `stats` framework. + + See Also + -------- + :func:`nodestat_func` + :func:`dinodestat_func` + :func:`diedgestat_func` + + """ + setattr(diedgestats, func.__name__, func) + return func diff --git a/xgi/stats/diedgestats.py b/xgi/stats/diedgestats.py new file mode 100644 index 000000000..024f39022 --- /dev/null +++ b/xgi/stats/diedgestats.py @@ -0,0 +1,353 @@ +"""Directed edge statistics. + +This module is part of the stats package, and it defines edge-level statistics. That +is, each function defined in this module is assumed to define an edge-quantity mapping. +Each callable defined here is accessible via a `Network` object, or a +:class:`~xgi.classes.reportviews.DiEdgeView` object. For more details, see the `tutorial +`_. + +Examples +-------- + +>>> import xgi +>>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) +>>> H.order() +{0: 3, 1: 2} +>>> H.edges.order.asdict() +{0: 3, 1: 2} + +""" + +__all__ = [ + "attrs", + "order", + "size", + "head_order", + "head_size", + "tail_order", + "tail_size", +] + + +def attrs(net, bunch, attr=None, missing=None): + """Access edge attributes. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Nodes in `net`. + attr : str | None (default) + If None, return all attributes. Otherwise, return a single attribute with name + `attr`. + missing : Any + Value to impute in case an edge does not have an attribute with name `attr`. + Default is None. + + Returns + ------- + dict + If attr is None, return a nested dict of the form `{edge: {"attr": val}}`. + Otherwise, return a simple dict of the form `{edge: val}`. + + Notes + ----- + When requesting all attributes (i.e. when `attr` is None), no value is imputed. + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph() + >>> edges = [ + ... ([{0, 1}, {2, 4}], 'one', {'color': 'red'}), + ... ([{1, 2}, {2, 0}], 'two', {'color': 'black', 'age': 30}), + ... ([{2, 3, 4}, {1}], 'three', {'color': 'blue', 'age': 40}), + ... ] + >>> H.add_edges_from(edges) + + Access all attributes as different types. + + >>> H.edges.attrs.asdict() # doctest: +NORMALIZE_WHITESPACE + {'one': {'color': 'red'}, + 'two': {'color': 'black', 'age': 30}, + 'three': {'color': 'blue', 'age': 40}} + >>> H.edges.attrs.asnumpy() # doctest: +NORMALIZE_WHITESPACE + array([{'color': 'red'}, + {'color': 'black', 'age': 30}, + {'color': 'blue', 'age': 40}], + dtype=object) + + Access a single attribute as different types. + + >>> H.edges.attrs('color').asdict() + {'one': 'red', 'two': 'black', 'three': 'blue'} + >>> H.edges.attrs('color').aslist() + ['red', 'black', 'blue'] + + By default, None is imputed when a node does not have the requested attribute. + + >>> H.edges.attrs('age').asdict() + {'one': None, 'two': 30, 'three': 40} + + Use `missing` to change the imputed value. + + >>> H.edges.attrs('age', missing=100).asdict() + {'one': 100, 'two': 30, 'three': 40} + + """ + if isinstance(attr, str): + return {e: net._edge_attr[e].get(attr, missing) for e in bunch} + elif attr is None: + return {e: net._edge_attr[e] for e in bunch} + else: + raise ValueError('"attr" must be str or None') + + +def order(net, bunch, degree=None): + """Edge order. + + The order of a directed edge is the number of nodes + contained in the union of the head and the tail minus 1. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Edges in `net`. + degree : int | None + If not None (default), count only those member nodes with the specified degree. + + Returns + ------- + dict + + See Also + -------- + size + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.edges.order.asdict() + {0: 3, 1: 2} + """ + if degree is None: + return {e: len(net._edge_in[e].union(net._edge_out[e])) - 1 for e in bunch} + else: + return { + e: sum( + len(net._node_in[n].union(net._node_out[n])) == degree + for n in net._edge_in[e].union(net._edge_out[e]) + ) + - 1 + for e in bunch + } + + +def size(net, bunch, degree=None): + """Edge size. + + The size of a directed edge is the number of nodes + contained in the union of the head and the tail. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Edges in `net`. + + Returns + ------- + dict + + See Also + -------- + order + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.edges.size.asdict() + {0: 4, 1: 3} + """ + if degree is None: + return {e: len(net._edge_in[e].union(net._edge_out[e])) for e in bunch} + else: + return { + e: sum( + 1 + for n in net._edge_in[e].union(net._edge_out[e]) + if len(net._node_in[n].union(net._node_out[n])) == degree + ) + for e in bunch + } + + +def tail_order(net, bunch, degree=None): + """Tail order. + + The order of the tail is the number of nodes it contains minus 1. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Edges in `net`. + + Returns + ------- + dict + + See Also + -------- + order + + Examples + -------- + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.edges.tail_order.asdict() + {0: 1, 1: 0} + + """ + if degree is None: + return {e: len(net._edge_out[e]) - 1 for e in bunch} + else: + return { + e: sum( + 1 + for n in net._edge_out[e] + if len(net._node_in[n].union(net._node_out[n])) == degree + ) + - 1 + for e in bunch + } + + +def tail_size(net, bunch, degree=None): + """Tail size. + + The size of the tail is the number of nodes it contains. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Edges in `net`. + + Returns + ------- + dict + + See Also + -------- + order + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.edges.tail_size.asdict() + {0: 2, 1: 1} + """ + if degree is None: + return {e: len(net._edge_out[e]) for e in bunch} + else: + return { + e: sum( + 1 + for n in net._edge_out[e] + if len(net._node_in[n].union(net._node_out[n])) == degree + ) + for e in bunch + } + + +def head_order(net, bunch, degree=None): + """Head order. + + The order of the head is the number of nodes it contains minus 1. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Edges in `net`. + + Returns + ------- + dict + + See Also + -------- + order + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.edges.head_order.asdict() + {0: 1, 1: 1} + """ + if degree is None: + return {e: len(net._edge_in[e]) - 1 for e in bunch} + else: + return { + e: sum( + 1 + for n in net._edge_in[e] + if len(net._node_in[n].union(net._node_out[n])) == degree + ) + - 1 + for e in bunch + } + + +def head_size(net, bunch, degree=None): + """Head size. + + The size of the head is the number of nodes it contains. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Edges in `net`. + + Returns + ------- + dict + + See Also + -------- + order + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.edges.head_size.asdict() + {0: 2, 1: 2} + """ + if degree is None: + return {e: len(net._edge_in[e]) for e in bunch} + else: + return { + e: sum( + 1 + for n in net._edge_in[e] + if len(net._node_in[n].union(net._node_out[n])) == degree + ) + for e in bunch + } diff --git a/xgi/stats/dinodestats.py b/xgi/stats/dinodestats.py new file mode 100644 index 000000000..9e8adb566 --- /dev/null +++ b/xgi/stats/dinodestats.py @@ -0,0 +1,281 @@ +"""Node statistics. + +This module is part of the stats package, and it defines node-level statistics. That +is, each function defined in this module is assumed to define a node-quantity mapping. +Each callable defined here is accessible via a `Network` object, or a +:class:`~xgi.classes.reportviews.NodeView` object. For more details, see the `tutorial +`_. + +Examples +-------- + +>>> import xgi +>>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) +>>> H.degree() +{1: 2, 2: 1, 5: 1, 6: 1, 4: 1, 3: 1} +>>> H.nodes.degree.asdict() +{1: 2, 2: 1, 5: 1, 6: 1, 4: 1, 3: 1} + +""" + +import numpy as np + +import xgi + +__all__ = [ + "attrs", + "degree", + "in_degree", + "out_degree", +] + + +def attrs(net, bunch, attr=None, missing=None): + """Access node attributes. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Nodes in `net`. + attr : str | None (default) + If None, return all attributes. Otherwise, return a single attribute with name + `attr`. + missing : Any + Value to impute in case a node does not have an attribute with name `attr`. + Default is None. + + Returns + ------- + dict + If attr is None, return a nested dict of the form `{node: {"attr": val}}`. + Otherwise, return a simple dict of the form `{node: val}`. + + Notes + ----- + When requesting all attributes (i.e. when `attr` is None), no value is imputed. + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph() + >>> H.add_nodes_from([ + ... (1, {"color": "red", "name": "horse"}), + ... (2, {"color": "blue", "name": "pony"}), + ... (3, {"color": "yellow", "name": "zebra"}), + ... (4, {"color": "red", "name": "orangutan", "age": 20}), + ... (5, {"color": "blue", "name": "fish", "age": 2}), + ... ]) + + Access all attributes as different types. + + >>> H.nodes.attrs.asdict() # doctest: +NORMALIZE_WHITESPACE + {1: {'color': 'red', 'name': 'horse'}, + 2: {'color': 'blue', 'name': 'pony'}, + 3: {'color': 'yellow', 'name': 'zebra'}, + 4: {'color': 'red', 'name': 'orangutan', 'age': 20}, + 5: {'color': 'blue', 'name': 'fish', 'age': 2}} + >>> H.nodes.attrs.asnumpy() # doctest: +NORMALIZE_WHITESPACE + array([{'color': 'red', 'name': 'horse'}, + {'color': 'blue', 'name': 'pony'}, + {'color': 'yellow', 'name': 'zebra'}, + {'color': 'red', 'name': 'orangutan', 'age': 20}, + {'color': 'blue', 'name': 'fish', 'age': 2}], + dtype=object) + + Access a single attribute as different types. + + >>> H.nodes.attrs('color').asdict() + {1: 'red', 2: 'blue', 3: 'yellow', 4: 'red', 5: 'blue'} + >>> H.nodes.attrs('color').aslist() + ['red', 'blue', 'yellow', 'red', 'blue'] + + By default, None is imputed when a node does not have the requested attribute. + + >>> H.nodes.attrs('age').asdict() + {1: None, 2: None, 3: None, 4: 20, 5: 2} + + Use `missing` to change the imputed value. + + >>> H.nodes.attrs('age', missing=100).asdict() + {1: 100, 2: 100, 3: 100, 4: 20, 5: 2} + + """ + if isinstance(attr, str): + return {n: net._node_attr[n].get(attr, missing) for n in bunch} + elif attr is None: + return {n: net._node_attr[n] for n in bunch} + else: + raise ValueError('"attr" must be str or None') + + +def degree(net, bunch, order=None, weight=None): + """Node degree. + + The degree of a node is the number of edges it belongs to, + regardless of whether it is in the head or the tail. + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Nodes in `net`. + order : int | None + If not None (default), only count the edges of the given order. + weight : str | None + If not None, specifies the name of the edge attribute that determines the weight + of each edge. + + Returns + ------- + dict + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.nodes.degree.asdict() + {1: 2, 2: 1, 5: 1, 6: 1, 4: 1, 3: 1} + """ + if order is None and weight is None: + return {n: len(net._node_in[n].union(net._node_out[n])) for n in bunch} + if order is None and weight: + return { + n: sum( + net._edge_attr[e].get(weight, 1) + for e in net._node_in[n].union(net._node_out[n]) + ) + for n in bunch + } + if order is not None and weight is None: + return { + n: sum( + 1 + for e in net._node_in[n].union(net._node_out[n]) + if len(net._edge_in[e].union(net._edge_out[e])) == order + 1 + ) + for n in bunch + } + if order is not None and weight: + return { + n: sum( + net._edge_attr[e].get(weight, 1) + for e in net._node_in[n].union(net._node_out[n]) + if len(net._edge_in[e].union(net._edge_out[e])) == order + 1 + ) + for n in bunch + } + + +def in_degree(net, bunch, order=None, weight=None): + """Node in-degree. + + The in-degree of a node is the number of edges for which + the node is in the head (it is a receiver). + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Nodes in `net`. + order : int | None + If not None (default), only count the edges of the given order. + weight : str | None + If not None, specifies the name of the edge attribute that determines the weight + of each edge. + + Returns + ------- + dict + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.nodes.in_degree.asdict() + {1: 1, 2: 1, 5: 0, 6: 0, 4: 1, 3: 0} + """ + if order is None and weight is None: + return {n: len(net._node_in[n]) for n in bunch} + if order is None and weight: + return { + n: sum(net._edge_attr[e].get(weight, 1) for e in net._node_in[n]) + for n in bunch + } + if order is not None and weight is None: + return { + n: sum( + 1 + for e in net._node_in[n] + if len(net._edge_in[e].union(net._edge_out[e])) == order + 1 + ) + for n in bunch + } + if order is not None and weight: + return { + n: sum( + net._edge_attr[e].get(weight, 1) + for e in net._node_in[n] + if len(net._edge_in[e].union(net._edge_out[e])) == order + 1 + ) + for n in bunch + } + + +def out_degree(net, bunch, order=None, weight=None): + """Node out-degree. + + The out-degree of a node is the number of edges for which + the node is in the tail (it is a sender). + + Parameters + ---------- + net : xgi.Hypergraph + The network. + bunch : Iterable + Nodes in `net`. + order : int | None + If not None (default), only count the edges of the given order. + weight : str | None + If not None, specifies the name of the edge attribute that determines the weight + of each edge. + + Returns + ------- + dict + + Examples + -------- + >>> import xgi + >>> H = xgi.DiHypergraph([[{1, 2}, {5, 6}], [{4}, {1, 3}]]) + >>> H.nodes.out_degree.asdict() + {1: 1, 2: 0, 5: 1, 6: 1, 4: 0, 3: 1} + """ + if order is None and weight is None: + return {n: len(net._node_out[n]) for n in bunch} + if order is None and weight: + return { + n: sum(net._edge_attr[e].get(weight, 1) for e in net._node_out[n]) + for n in bunch + } + if order is not None and weight is None: + return { + n: sum( + 1 + for e in net._node_out[n] + if len(net._edge_in[e].union(net._edge_out[e])) == order + 1 + ) + for n in bunch + } + if order is not None and weight: + return { + n: sum( + net._edge_attr[e].get(weight, 1) + for e in net._node[n] + if len(net._edge_in[e].union(net._edge_out[e])) == order + 1 + ) + for n in bunch + }