Skip to content

Commit 1332d8e

Browse files
authored
Merge pull request #99 from anty-filidor/develop
Develop
2 parents a86b2b4 + 403d6af commit 1332d8e

7 files changed

Lines changed: 253 additions & 4 deletions

File tree

network_diffusion/mln/centralities.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any, Callable
1212

1313
import networkx as nx
14+
import torch
1415

1516
from network_diffusion.mln.actor import MLNetworkActor
1617
from network_diffusion.mln.functions import all_neighbours
@@ -310,3 +311,128 @@ def voterank_actorwise(
310311
vote_rank[nbr][1] = max(vote_rank[nbr][1], 0)
311312

312313
return influential_actors
314+
315+
316+
def _build_edge_tensors(
317+
net: MultilayerNetwork,
318+
actor_to_idx: dict[MLNetworkActor, int],
319+
device: torch.device,
320+
) -> tuple[torch.Tensor, torch.Tensor]:
321+
"""
322+
Build undirected edge index tensors from a multilayer network.
323+
324+
Each edge (a, b) is represented twice — as (a→b) and (b→a) — so that
325+
scatter-based aggregation covers both directions without special-casing.
326+
327+
:param net: multilayer network to extract edges from.
328+
:param actor_to_idx: mapping from actor to its integer index.
329+
:param device: torch device to place the resulting tensors on.
330+
:return: tuple (edge_src, edge_dst), each a 1-D long tensor of length 2E,
331+
where E is the number of undirected edges in the network.
332+
"""
333+
links = list(net.get_links())
334+
335+
if not links:
336+
empty = torch.empty(0, dtype=torch.long, device=device)
337+
return empty, empty
338+
339+
src = torch.tensor([actor_to_idx[a] for a, _ in links], dtype=torch.long)
340+
dst = torch.tensor([actor_to_idx[b] for _, b in links], dtype=torch.long)
341+
342+
edge_src = torch.cat([src, dst]).to(device)
343+
edge_dst = torch.cat([dst, src]).to(device)
344+
345+
return edge_src, edge_dst
346+
347+
348+
def _get_device(device: str | None) -> torch.device:
349+
"""
350+
Resolve device string to torch.device, auto-detecting if not provided.
351+
352+
:param device: device string (e.g. ``"cuda"``, ``"mps"``, ``"cpu"``,
353+
``"cuda:1"``), or ``None`` for auto-detection.
354+
:return: resolved :class:`torch.device` instance.
355+
"""
356+
if device:
357+
return torch.device(device)
358+
if torch.cuda.is_available():
359+
return torch.device("cuda")
360+
if torch.backends.mps.is_available():
361+
return torch.device("mps")
362+
return torch.device("cpu")
363+
364+
365+
def torch_voterank_actorwise(
366+
net: MultilayerNetwork,
367+
number_of_actors: int | None = None,
368+
device: str | None = None,
369+
) -> list[MLNetworkActor]:
370+
"""
371+
Select a list of influential ACTORS in a graph using VoteRank algorithm.
372+
373+
VoteRank computes a ranking of the actors in a graph based on a voting
374+
scheme. With VoteRank, all actors vote for each of its neighbours and the
375+
actor with the highest votes is elected iteratively. The voting ability of
376+
neighbors of elected actors is decreased in subsequent turns.
377+
378+
Optimized with PyTorch tensors — supports CUDA, MPS (float32), and CPU.
379+
380+
:param net: multilayer network.
381+
:param number_of_actors: number of ranked actors to extract (default all).
382+
:param device: torch device string e.g. "cuda", "mps", "cpu".
383+
Auto-detected if not provided (CUDA > MPS > CPU).
384+
:return: ordered list of computed seeds, only actors with positive number
385+
of votes are returned.
386+
"""
387+
if net.is_directed():
388+
raise NotImplementedError(
389+
"Voterank for directed networks is not implemented!"
390+
)
391+
if len(net) == 0:
392+
return []
393+
394+
# Step 1 actors — snapshot once, single source of truth for ordering
395+
actors: list[MLNetworkActor] = list(net.get_actors())
396+
n = len(actors)
397+
actor_to_idx = {a: i for i, a in enumerate(actors)}
398+
number_of_actors = min(number_of_actors or n, n)
399+
# Step 2 Build edge tensors
400+
edge_src, edge_dst = _build_edge_tensors(
401+
net=net,
402+
actor_to_idx=actor_to_idx,
403+
device=_get_device(device),
404+
)
405+
406+
inv_avg_nbs = torch.tensor(
407+
n / sum(neighbourhood_size(net=net).values()),
408+
dtype=torch.float32,
409+
device=edge_src.device,
410+
)
411+
# Step 3 VoteRank state
412+
scores = torch.zeros(n, dtype=torch.float32, device=edge_src.device)
413+
ability = torch.ones(n, dtype=torch.float32, device=edge_src.device)
414+
elected = torch.zeros(n, dtype=torch.bool, device=edge_src.device)
415+
416+
influential_actors: list[MLNetworkActor] = []
417+
418+
for _ in range(number_of_actors):
419+
scores.zero_()
420+
scores.scatter_add_(0, edge_dst, ability[edge_src])
421+
scores[elected] = 0.0
422+
423+
# Step 4 select top actor
424+
top_idx = int(scores.argmax().item())
425+
if scores[top_idx].item() == 0.0:
426+
break
427+
428+
influential_actors.append(actors[top_idx])
429+
elected[top_idx] = True
430+
ability[top_idx] = 0.0
431+
432+
# Step 5 weaken neighbours of elected actor
433+
neighbour_mask = edge_dst[edge_src == top_idx]
434+
if neighbour_mask.numel() > 0:
435+
ability[neighbour_mask] -= inv_avg_nbs
436+
ability.clamp_(min=0.0)
437+
438+
return influential_actors

network_diffusion/mln/mlnetwork.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@ def copy(self) -> "MultilayerNetwork":
193193
)
194194
return copied_instance
195195

196+
def save(self, path: str) -> None:
197+
"""Save the multilayer network as an mpx file."""
198+
dummy_net = multinet.empty()
199+
for l_name in self.layers:
200+
multinet.add_nx_layer(dummy_net, self[l_name], l_name)
201+
multinet.write(dummy_net, path, "multilayer")
202+
196203
def subgraph(self, actors: list[MLNetworkActor]) -> "MultilayerNetwork":
197204
"""
198205
Return a subgraph of the network.

network_diffusion/nets/mln_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def get_dependency(self) -> np.ndarray:
4545
and 1/nb_layers otherwise
4646
"""
4747
dep = np.full(
48-
fill_value=(1 / self.nb_layers),
48+
fill_value=(1 / (self.nb_layers - 1)),
4949
shape=(self.nb_layers, self.nb_layers),
5050
)
5151
np.fill_diagonal(dep, 0)

network_diffusion/seeding/voterank_selector.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import networkx as nx
1414

1515
from network_diffusion.mln.actor import MLNetworkActor
16-
from network_diffusion.mln.centralities import voterank_actorwise
16+
from network_diffusion.mln.centralities import torch_voterank_actorwise
1717
from network_diffusion.mln.mlnetwork import MultilayerNetwork
1818
from network_diffusion.seeding.base_selector import (
1919
BaseSeedSelector,
@@ -52,6 +52,11 @@ def actorwise(self, net: MultilayerNetwork) -> list[MLNetworkActor]:
5252
class VoteRankMLNSeedSelector(BaseSeedSelector):
5353
"""Selector for MLTModel based on Vote Rank algorithm."""
5454

55+
def __init__(self, device: str | None = None, **kwargs: Any) -> None:
56+
"""Initialise the object."""
57+
super().__init__(**kwargs)
58+
self.device = device
59+
5560
def _calculate_ranking_list(self, graph: nx.Graph) -> list[Any]:
5661
"""Create nodewise ranking."""
5762
raise NotImplementedError(
@@ -67,7 +72,7 @@ def __str__(self) -> str:
6772

6873
def actorwise(self, net: MultilayerNetwork) -> list[MLNetworkActor]:
6974
"""Compute ranking for actors."""
70-
elected_nodes = voterank_actorwise(net=net)
75+
elected_nodes = torch_voterank_actorwise(net=net, device=self.device)
7176
unelected_nodes = set(net.get_actors()).difference(set(elected_nodes))
7277
elected_nodes.extend(unelected_nodes)
7378
return elected_nodes

network_diffusion/tests/mln/test_functions.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
degree,
1010
multiplexing_coefficient,
1111
neighbourhood_size,
12+
torch_voterank_actorwise,
13+
voterank_actorwise,
1214
)
1315
from network_diffusion.mln.functions import remove_selfloop_edges
1416
from network_diffusion.nets import get_toy_network_piotr
@@ -75,6 +77,15 @@
7577
11: 3,
7678
}
7779

80+
VOTERANK_NET_1_TOP_5 = [
81+
"Medici",
82+
"Peruzzi",
83+
"Guadagni",
84+
"Strozzi",
85+
"Barbadori",
86+
]
87+
VOTERANK_NET_2_TOP_5 = [2, 6, 10, 9, 4]
88+
7889

7990
class TestFunctions(unittest.TestCase):
8091
"""Test functions."""
@@ -138,6 +149,45 @@ def test_remove_selfloop_edges(self):
138149
list(nx.selfloop_edges(self.network_1[l_name])), []
139150
)
140151

152+
def test_voterank_consistency(self):
153+
"""Test that voterank_actorwise and torch_voterank_actorwise produce identical results."""
154+
for network in [self.network_1, self.network_2]:
155+
# Test with all actors
156+
result_cpu = voterank_actorwise(network)
157+
result_torch = torch_voterank_actorwise(network)
158+
self.assertEqual(
159+
[a.actor_id for a in result_cpu],
160+
[a.actor_id for a in result_torch],
161+
"VoteRank results should be identical for all actors",
162+
)
163+
# Test with limited number of actors
164+
num_actors = min(5, len(network))
165+
result_cpu_limited = voterank_actorwise(
166+
network, number_of_actors=num_actors
167+
)
168+
result_torch_limited = torch_voterank_actorwise(
169+
network, number_of_actors=num_actors
170+
)
171+
self.assertEqual(
172+
[a.actor_id for a in result_cpu_limited],
173+
[a.actor_id for a in result_torch_limited],
174+
f"VoteRank results should be identical for {num_actors} actors",
175+
)
176+
177+
def test_voterank_actorwise(self):
178+
"""Ensure VoteRank returns expected hardcoded actor sequences."""
179+
for network, expected in zip(
180+
[self.network_1, self.network_2],
181+
[VOTERANK_NET_1_TOP_5, VOTERANK_NET_2_TOP_5],
182+
):
183+
result = voterank_actorwise(network)
184+
actor_ids = [a.actor_id for a in result[:5]]
185+
self.assertEqual(
186+
actor_ids,
187+
expected,
188+
"VoteRank output should match the hardcoded sequence",
189+
)
190+
141191

142192
if __name__ == "__main__":
143193
unittest.main(verbosity=2)

network_diffusion/tests/mln/test_mlnetwork.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import copy
22
import os
3+
import tempfile
34
import unittest
45

56
import networkx as nx
@@ -182,6 +183,66 @@ def test_to_multiplex(self):
182183
"business": {"Ridolfi", "Albizzi", "Acciaiuoli", "Strozzi"},
183184
}
184185

186+
def test_save_creates_file(self):
187+
"""Test that save() method creates a file."""
188+
with tempfile.TemporaryDirectory() as tmpdir:
189+
file_path = os.path.join(tmpdir, "test_network.mpx")
190+
self.florentine.save(file_path)
191+
self.assertTrue(
192+
os.path.exists(file_path),
193+
"Save method should create an MPX file",
194+
)
195+
196+
def test_save_and_load_roundtrip(self):
197+
"""Test save and load roundtrip preserves network structure."""
198+
with tempfile.TemporaryDirectory() as tmpdir:
199+
file_path = os.path.join(tmpdir, "test_network.mpx")
200+
201+
# Save the network
202+
self.florentine.save(file_path)
203+
204+
# Load it back
205+
loaded_network = MultilayerNetwork.from_mpx(file_path)
206+
207+
# Verify basic properties are preserved
208+
self.assertEqual(
209+
set(self.florentine.get_layer_names()),
210+
set(loaded_network.get_layer_names()),
211+
"Layer names should be preserved after save/load",
212+
)
213+
self.assertEqual(
214+
self.florentine.get_actors_num(),
215+
loaded_network.get_actors_num(),
216+
"Number of actors should be preserved after save/load",
217+
)
218+
219+
def test_save_preserves_edges(self):
220+
"""Test that save() preserves edge information for each layer."""
221+
with tempfile.TemporaryDirectory() as tmpdir:
222+
file_path = os.path.join(tmpdir, "test_network.mpx")
223+
224+
# Get original edge counts per layer
225+
original_edge_counts = {
226+
l_name: len(self.florentine[l_name].edges())
227+
for l_name in self.florentine.get_layer_names()
228+
}
229+
230+
# Save and load
231+
self.florentine.save(file_path)
232+
loaded_network = MultilayerNetwork.from_mpx(file_path)
233+
234+
# Verify edge counts match
235+
loaded_edge_counts = {
236+
l_name: len(loaded_network[l_name].edges())
237+
for l_name in loaded_network.get_layer_names()
238+
}
239+
240+
self.assertEqual(
241+
original_edge_counts,
242+
loaded_edge_counts,
243+
"Edge counts should be preserved in each layer",
244+
)
245+
185246

186247
if __name__ == "__main__":
187248
unittest.main(verbosity=2)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "network_diffusion"
7-
version = "0.18.1"
7+
version = "0.19.0"
88
requires-python = ">=3.12"
99
authors = [
1010
{name = "Michał Czuba", email="michal.czuba@pwr.edu.pl"},

0 commit comments

Comments
 (0)