From 6d3b776209d77c12f851d1f4ae6e2ff043971a91 Mon Sep 17 00:00:00 2001 From: Florian Dejonckheere Date: Sun, 14 Apr 2024 14:30:47 +0300 Subject: [PATCH] Louvain: reduce communities to a single node --- lib/mosaik/algorithms/louvain.rb | 129 +++++++++++++++++++++---- spec/mosaik/algorithms/louvain_spec.rb | 24 +++++ 2 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 spec/mosaik/algorithms/louvain_spec.rb diff --git a/lib/mosaik/algorithms/louvain.rb b/lib/mosaik/algorithms/louvain.rb index 0d08fbc..89a83db 100644 --- a/lib/mosaik/algorithms/louvain.rb +++ b/lib/mosaik/algorithms/louvain.rb @@ -10,46 +10,82 @@ class Louvain < Algorithm EPSILON = 1e-6 def call - # Assign initial set of communities (each vertex in its own community) - graph.vertices.each_value.with_index do |vertex, i| - graph - .add_cluster("C#{i}") - .add_vertex(vertex) - end - # Calculate initial modularity - modularity = modularity_for(graph) + modularity = modularity(graph) info "Initial modularity: #{modularity}" + # Use a separate variable to store the (reduced) graph through the iterations + reduced_graph = graph + + # Final mapping of vertices to communities + mapping = graph + .vertices + .keys + .index_with { |vertex_id| vertex_id } + # Iterate until no further improvement in modularity 1.step do |i| - debug "Iteration #{i}: initial modularity = #{modularity}" + # Assign initial set of communities (each vertex in its own community) + reduced_graph.vertices.each_value do |vertex| + reduced_graph + .add_cluster(vertex.id) + .add_vertex(vertex) + end + + # Calculate initial modularity + initial_modularity = modularity(reduced_graph) + + debug "Iteration #{i}: modularity=#{initial_modularity}, vertices=#{reduced_graph.vertices.count}, communities=#{reduced_graph.clusters.count}" # Phase 1: reassign vertices to optimize modularity - graph.vertices.each_value do |vertex| - reassign_vertex(vertex) + reduced_graph.vertices.each_value do |vertex| + reassign_vertex(reduced_graph, vertex) end # Phase 2: reduce communities to a single node - # TODO: Implement this phase + g, reduced_mapping = reduce_graph(reduced_graph) + + debug "Reduced #{reduced_graph.vertices.count} vertices to #{g.vertices.count} vertices" + debug "Mapping: #{reduced_mapping.inspect}" + debug "Changes: #{reduced_mapping.select { |a, b| a != b }.inspect}" + + if options[:visualize] + MOSAIK::Graph::Visualizer + .new(options, g) + .to_svg("louvain_#{i}") + end + + # Merge the reduced mapping with the original mapping + mapping = mapping.transform_values { |v| reduced_mapping[v] } # Calculate final modularity - final_modularity = modularity_for(graph) + final_modularity = modularity(graph) # Stop iterating if no further improvement in modularity break if final_modularity - modularity <= EPSILON # Update modularity modularity = final_modularity + + # Update the reduced graph + reduced_graph = g end info "Final modularity: #{modularity}" + + # Copy the final communities to the original graph + graph.clusters.clear + mapping.each do |vertex_id, community_id| + graph + .find_or_add_cluster(community_id) + .add_vertex(graph.find_vertex(vertex_id)) + end end private - def reassign_vertex(vertex) + def reassign_vertex(graph, vertex) # Initialize best community as current community best_community = graph.clusters.values.find { |cluster| cluster.vertices.include? vertex } @@ -57,13 +93,14 @@ def reassign_vertex(vertex) best_gain = 0.0 # Initialize best modularity - best_modularity = modularity_for(graph) + best_modularity = modularity(graph) # Store the original community of the vertex community = graph.clusters.values.find { |cluster| cluster.vertices.include? vertex } # Iterate over all neighbours of the vertex vertex.edges.each_key do |neighbour_id| + # Find the community of the neighbour neighbour = graph.find_vertex(neighbour_id) neighbour_community = graph.clusters.values.find { |cluster| cluster.vertices.include? neighbour } @@ -75,7 +112,7 @@ def reassign_vertex(vertex) neighbour_community.add_vertex(vertex) # Calculate the new modularity - new_modularity = modularity_for(graph) + new_modularity = modularity(graph) # Calculate the modularity gain gain = new_modularity - best_modularity @@ -99,7 +136,65 @@ def reassign_vertex(vertex) best_modularity end - def modularity_for(graph) + def reduce_graph(graph) + raise NotImplementedError, "Directed graphs are not supported" if graph.directed + + # Create a new graph + reduced_graph = Graph::Graph.new(directed: graph.directed) + + # Mapping of vertices to communities + reduced_mapping = graph + .clusters + .each_with_object({}) { |(community_id, cluster), mapping| cluster.vertices.each { |vertex| mapping[vertex.id] = community_id } } + + # Iterate over all communities + graph.clusters.each_value do |cluster| + # Create a new vertex for the community + reduced_graph.add_vertex(cluster.id) + end + + # Iterate over all combinations of vertices + weights = graph.vertices.keys.combination(2).filter_map do |v1, v2| + # Find all edges between the two vertices + edges = Set.new(graph.find_edges(v1, v2) + graph.find_edges(v2, v1)) + + # Skip if there are no edges + next if edges.empty? + + # Find the communities of the vertices + c1 = reduced_mapping[v1] + c2 = reduced_mapping[v2] + + # Skip if the communities are the same + next if c1 == c2 + + # Calculate the weight for the aggregate edge + weight = edges.sum { |e| e.attributes.fetch(:weight, 0.0) } + + [[c1, c2], weight] + end + + # Transform weights into a hash + weights = weights + .group_by(&:first) + .transform_values { |es| es.sum(&:last) } + + # Add new edges to the reduced graph + reduced_graph.vertices.keys.combination(2).each do |v1, v2| + weight = weights.fetch([v1, v2], 0.0) + weights.fetch([v2, v1], 0.0) + + # Skip if the weight is zero + next if weight.zero? + + reduced_graph + .add_edge(v1, v2, weight:) + end + + # Return the reduced graph and mapping + [reduced_graph, reduced_mapping] + end + + def modularity(graph) Metrics::Modularity .new(options, graph) .evaluate diff --git a/spec/mosaik/algorithms/louvain_spec.rb b/spec/mosaik/algorithms/louvain_spec.rb new file mode 100644 index 0000000..4764661 --- /dev/null +++ b/spec/mosaik/algorithms/louvain_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# typed: true + +RSpec.describe MOSAIK::Algorithms::Louvain do + subject(:algorithm) { described_class.new(options, graph) } + + let(:options) { {} } + + include_context "with a simple undirected graph" + + describe "#reduce_graph" do + it "returns a reduced graph" do + reduced_graph, reduced_mapping = algorithm.send(:reduce_graph, graph) + + expect(reduced_graph.vertices.keys).to eq ["A", "B", "C"] + + expect(reduced_graph.find_vertex("A").edges.transform_values { |es| es.map(&:attributes) }).to eq "B" => [{ weight: 3.0 }], "C" => [{ weight: 2.0 }] + expect(reduced_graph.find_vertex("B").edges.transform_values { |es| es.map(&:attributes) }).to eq "A" => [{ weight: 3.0 }] + expect(reduced_graph.find_vertex("C").edges.transform_values { |es| es.map(&:attributes) }).to eq "A" => [{ weight: 2.0 }] + + expect(reduced_mapping).to eq "A" => "A", "B" => "A", "C" => "B", "D" => "A", "E" => "B", "F" => "C" + end + end +end