From 43089114c2b0cadc916adba4f2ebe393275ba138 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 | 122 +++++++++++++++++++++++-- spec/mosaik/algorithms/louvain_spec.rb | 32 +++++++ 2 files changed, 144 insertions(+), 10 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..5d67844 100644 --- a/lib/mosaik/algorithms/louvain.rb +++ b/lib/mosaik/algorithms/louvain.rb @@ -10,46 +10,89 @@ 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 + debug "Calculating initial modularity" # Calculate initial modularity modularity = modularity_for(graph) info "Initial modularity: #{modularity}" + # Store the original graph + original_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) + graph.vertices.each_value do |vertex| + graph + .add_cluster(vertex.id) + .add_vertex(vertex) + end + + debug "Iteration #{i}: start modularity=#{modularity}, vertices=#{graph.vertices.count}, communities=#{graph.clusters.count}" # Phase 1: reassign vertices to optimize modularity graph.vertices.each_value do |vertex| - reassign_vertex(vertex) + reassign_vertex(graph, vertex) end # Phase 2: reduce communities to a single node - # TODO: Implement this phase + g, reduced_mapping = reduce_graph(graph) + + yield g, reduced_mapping if block_given? + debug "Reduced #{graph.vertices.size} vertices to #{g.vertices.size} vertices" + debug "Mapping: #{reduced_mapping.inspect}" + debug "Changes: #{reduced_mapping.reject { |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) + info "Iteration #{i}: end modularity=#{final_modularity}, vertices=#{graph.vertices.count}, communities=#{graph.clusters.count}" + # Stop iterating if no further improvement in modularity break if final_modularity - modularity <= EPSILON # Update modularity modularity = final_modularity + + # Update the reduced graph + @graph = g end info "Final modularity: #{modularity}" + + # Copy the final communities to the original graph + original_graph.clusters.clear + + mapping.each do |vertex_id, community_id| + original_graph + .find_or_add_cluster(community_id) + .add_vertex(original_graph.find_vertex(vertex_id)) + rescue => e + binding.b + + raise + 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 } @@ -64,6 +107,7 @@ def reassign_vertex(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 } @@ -99,6 +143,64 @@ def reassign_vertex(vertex) best_modularity end + 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 (non-empty) community + reduced_graph.add_vertex(cluster.id) if cluster.vertices.any? + 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].sort, 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_for(graph) Metrics::Modularity .new(options, graph) diff --git a/spec/mosaik/algorithms/louvain_spec.rb b/spec/mosaik/algorithms/louvain_spec.rb new file mode 100644 index 0000000..51c2862 --- /dev/null +++ b/spec/mosaik/algorithms/louvain_spec.rb @@ -0,0 +1,32 @@ +# 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 "#call" do + it "optimizes modularity" do + expect { algorithm.call }.to change { graph.clusters.count }.from(0).to(3) + + expect(graph.clusters.map { |_, cluster| cluster.vertices.map(&:id) }).to match_array [["A", "B"], ["C"], ["D", "E", "F"]] + end + end + + 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