diff --git a/.gitignore b/.gitignore index 4a19478d..31d1399b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ logfiles/ stashmerge.sh jacksnbs demos +uv.lock __pycache__ *.ipynb_checkpoints diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ed2fed4..e45be554 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ We want this community to be friendly and respectful to each other. Please read 1. Create a Python virtual environment. For convenience, our Makefile provides a target called 'venv' that will create a virtual environment for you. Run the following command: `make venv` 1. Activate the virtual environment. If you used the Makefile target in the previous step, activate the virtual environment by the running the following command: `source venv-hnx/bin/activate` 1. Install the library in development mode: `pip install -e .` -1. Install testing dependencies: `pip install -e .['testing'] ` +1. Install testing dependencies: `pip install -e '.[testing]' ` 1. Do the changes you want and ensure all tests pass by running `python -m pytest` before sending a pull request. ### Commit message convention diff --git a/hypernetx/classes/hypergraph.py b/hypernetx/classes/hypergraph.py index 02d44cc3..0218c3fb 100644 --- a/hypernetx/classes/hypergraph.py +++ b/hypernetx/classes/hypergraph.py @@ -2392,6 +2392,64 @@ def distance(self, source, target, s=1): return dist + def k_nearest_neighbors(self, node, k, s=1): + """ + Return nodes nearest to ``node`` by shortest :term:`s-walk` distance. + + This uses the same s-walk metric as :meth:`distance`, not the + s-edge-sharing definition in :meth:`neighbors`. + + Parameters + ---------- + node : hashable + A node in the hypergraph. + + k : int + Consider the ``k``-th smallest s-walk distance among other nodes, + then return every other node at distance less than or equal to + that value. Ties at the cutoff may yield more than ``k`` nodes. + + s : positive int, optional, default=1 + Minimum number of shared edges between consecutive nodes in an + s-walk. + + Returns + ------- + list + Node uids of the k-nearest neighbors (including ties). + + See Also + -------- + distance + neighbors + get_linegraph + + Notes + ----- + Nodes in other s-components are omitted. Distances match + :meth:`distance` for each returned neighbor. + """ + if k <= 0: + return [] + if node not in self.nodes: + warnings.warn(f"{node} is not in hypergraph {self.name}.") + return [] + + g = self.get_linegraph(s=s, edges=False) + try: + lengths = nx.single_source_shortest_path_length(g, node) + except nx.NodeNotFound: + return [] + + candidates = [(u, d) for u, d in lengths.items() if u != node] + if not candidates: + return [] + + candidates.sort(key=lambda pair: (pair[1], pair[0])) + k_eff = min(k, len(candidates)) + kth_dist = candidates[k_eff - 1][1] + return [u for u, d in candidates if d <= kth_dist] + def edge_distance(self, source, target, s=1): """ Returns the shortest :term:`s-walk` distance between two edges in the hypergraph. diff --git a/tests/classes/test_hypergraph.py b/tests/classes/test_hypergraph.py index 29ceb5f2..39f63116 100644 --- a/tests/classes/test_hypergraph.py +++ b/tests/classes/test_hypergraph.py @@ -1445,6 +1445,66 @@ def test_distance(lesmis): assert h.distance("ME", "FN", s=3) == np.inf +def test_k_nearest_neighbors(lesmis): + h = Hypergraph(lesmis.edgedict) + knn1 = h.k_nearest_neighbors("ME", 1) + assert set(knn1) == { + "CL", + "CV", + "GE", + "GG", + "IS", + "JL", + "JV", + "MB", + "MC", + "MR", + "MT", + "MY", + "NP", + "PG", + "SN", + } + for u in knn1: + assert h.distance("ME", u) == 1 + + knn_s2 = h.k_nearest_neighbors("ME", 1, s=2) + assert set(knn_s2) == {"MB", "MY"} + for u in knn_s2: + assert h.distance("ME", u, s=2) == 1 + + +def test_k_nearest_neighbors_sevenbysix(sevenbysix): + hg = Hypergraph(sevenbysix.edgedict) + assert set(hg.k_nearest_neighbors("A", 1)) == {"C", "E", "K", "T2", "V"} + + +def test_k_nearest_neighbors_ties(): + h = Hypergraph({"e1": ["v", "a"], "e2": ["v", "b"], "e3": ["v", "c"]}) + knn = h.k_nearest_neighbors("v", 1) + assert set(knn) == {"a", "b", "c"} + assert len(knn) > 1 + + +def test_k_nearest_neighbors_invalid_node(lesmis): + h = Hypergraph(lesmis.edgedict) + with pytest.warns(UserWarning, match="NEMO is not in hypergraph"): + assert h.k_nearest_neighbors("NEMO", 1) == [] + + +def test_k_nearest_neighbors_k_nonpositive(lesmis): + h = Hypergraph(lesmis.edgedict) + assert h.k_nearest_neighbors("ME", 0) == [] + assert h.k_nearest_neighbors("ME", -1) == [] + + +def test_k_nearest_neighbors_k_large(lesmis): + h = Hypergraph(lesmis.edgedict) + knn = h.k_nearest_neighbors("ME", 1000) + assert len(knn) == len(h.nodes) - 1 + assert "ME" not in knn + + def test_edge_distance(lesmis): h = Hypergraph(lesmis.edgedict) assert h.edge_distance(1, 4) == 2