Back to Graph Theory Series

Louvain Method

September 20, 2026 Wasil Zafar 26 min read

Let each vertex ask, “Which neighboring group improves the network’s modularity most?” Then compress the groups, ask the same question again, and uncover communities at several scales.

Contents

  1. A Bit of History
  2. Community Intuition
  3. Modularity
  4. The Two Phases
  5. Visual Walkthrough
  6. Resolution & Randomness
  7. Implementation
  8. Complexity Analysis
  9. Choosing a Method
  10. Real-World Applications
  11. Exercises
  12. Limitations

A Bit of History

From modularity to fast unfolding

2004Newman and Girvan popularize modularity as a way to score a proposed community partition.
2008Vincent Blondel, Jean-Loup Guillaume, Renaud Lambiotte, and Etienne Lefebvre publish the Louvain method, named after their Belgian university.
2019The Leiden algorithm adds a refinement phase and guarantees better-connected communities under its quality conditions.

The important shift was computational: instead of repeatedly calculating expensive global edge scores, Louvain makes cheap local moves and periodically shrinks the graph. That combination made modularity optimization practical on much larger sparse networks.

What Counts as a Community?

A community is not merely a visibly dense patch. It is a group whose internal connectivity is surprisingly high relative to a degree-preserving null model. A celebrity with thousands of links should contribute less evidence than the same link from a low-degree vertex, because the celebrity would connect widely even in a randomized network.

Observed signal

How much edge weight actually falls inside each proposed group?

Random baseline

How much internal weight would be expected after random rewiring that preserves vertex strengths?

Modularity

The excess of observed internal connection over that baseline, summed across groups.

Party-room analogy. A person does not choose the room containing the most people; they choose the room where their friendships are unexpectedly concentrated. Louvain repeatedly makes that local choice, then treats each settled room as a single guest at the next floor of the party.

Modularity, Term by Term

For an undirected weighted graph, let $A_{ij}$ be the edge weight between $i$ and $j$, $k_i=\sum_j A_{ij}$ be vertex $i$’s strength, and $m$ be the total undirected edge weight. A common resolution-aware form is:

$$Q = \frac{1}{2m}\sum_{i,j}\left(A_{ij}-\gamma\frac{k_i k_j}{2m}\right)\delta(c_i,c_j)$$

$A_{ij}$ = observed weight $k_i k_j/(2m)$ = expected weight $\delta(c_i,c_j)$ = same-group gate $\gamma$ = resolution

The Kronecker delta discards every pair in different communities. The remaining terms reward unexpectedly strong internal edges and penalize groups whose total degree alone would make many internal edges unsurprising. An equivalent community-level form is often easier to compute:

$$Q=\sum_C\left[\frac{w_{\mathrm{in}}(C)}{m}-\gamma\left(\frac{\mathrm{vol}(C)}{2m}\right)^2\right]$$

Here $w_{\mathrm{in}}(C)$ counts each undirected internal edge once, while $\mathrm{vol}(C)=\sum_{i\in C}k_i$. Modularity is a score for a partition, not proof that the partition is uniquely correct.

Reading the sign of $\Delta Q$

A positive $\Delta Q$ means the proposed move improves the objective; zero gives no improvement; negative makes the partition worse. Louvain accepts only improving moves, so modularity never decreases during a run—although the final local optimum can depend on vertex order and tie-breaking.

The Two Alternating Phases

  1. Initialize. Put every current vertex in its own community and record community volumes. At later levels, a “vertex” may already represent many original vertices.
  2. Remove and test. Temporarily remove vertex $i$ from its group. Accumulate its edge weight into each neighboring community and evaluate the insertion score for each candidate.
  3. Take the best improving move. Insert $i$ into the community with the largest positive modularity gain. Repeat vertex sweeps until a complete sweep makes no move.
  4. Contract. Replace each discovered community by one super-node. Sum parallel inter-community edges; turn internal edges into self-loops. Total edge weight is conserved.
  5. Repeat and lift labels. Run local moving on the smaller weighted graph. When no level improves modularity, map the super-node labels back to the original vertices.

After temporarily removing $i$, implementations compare candidate communities using a scaled insertion score:

$$\operatorname{score}(i\rightarrow C)=k_{i\rightarrow C}-\gamma\frac{k_i\,\mathrm{vol}(C)}{2m}$$

The first term favors communities connected strongly to $i$; the second resists dumping everything into one enormous group. The omitted positive constant is shared by all candidates, so comparing these scores gives the same choice as comparing exact modularity gains.

A Louvain local-move decision A boundary vertex X is weakly connected to the teal community and strongly connected to the blue community. A decision panel shows that Louvain compares observed attachment with a degree-based expectation. 1 · Test one boundary vertex TEAL COMMUNITY BLUE COMMUNITY X Line thickness represents edge weight. Candidate score attachment − expected share Teal lower Blue higher DECISION Move X only if the exact modularity change is positive.
Local move intuition. Stronger attachment helps, but Louvain also subtracts the connection expected from the vertex’s strength and the candidate community’s volume. Counting neighbors alone is not sufficient.

Visual Walkthrough: From Vertices to Super-Nodes

Suppose a local phase settles eight vertices into three groups. Contraction does not throw information away: it records internal edge weight as a loop and sums every edge crossing the same pair of groups. The next local phase therefore optimizes the same objective on a smaller representation.

Three levels of Louvain community contraction Eight original vertices form three communities, which become three weighted super-nodes. Two super-nodes merge at the next level, producing a hierarchy. LEVEL 0 · original graph LEVEL 1 · contracted LEVEL 2 · coarser CONTRACT ABC loops = internal weight REPEAT A + B6 verticesC Final labels are lifted back to all original vertices. Contraction preserves edge weight; it changes the scale at which moves are considered.
The hierarchy. Level 0 can move individual vertices; later levels move whole previously discovered groups. Saving every level provides a multiscale view rather than only one flat partition.
Core intuition: local moving improves the partition at the current scale; contraction creates a new scale. Louvain’s speed and hierarchy come from alternating those two ideas.

Resolution, Order, and Stopping

ControlWhat it changesPractical guidance
Resolution $\gamma$Higher values penalize large-volume groups more, usually producing smaller communities; lower values favor larger ones.Sweep several values and inspect stability instead of treating $\gamma=1$ as universally correct.
Vertex orderGreedy moves alter the choices available later in the same sweep.Run several randomized seeds; compare modularity and membership stability.
ToleranceDefines how much gain counts as meaningful and when a level stops.Use a small positive tolerance to avoid floating-point churn.
Edge weightsStronger relationships contribute more to degrees, attachment, and modularity.Require finite, nonnegative weights for the standard formulation and document preprocessing.

Disconnected components can be processed independently. An isolated vertex has zero strength and contributes neither reward nor penalty, so it can remain a singleton. Standard modularity is not designed for arbitrary negative weights; signed networks require a modified objective.

Implementation

The tabs below implement the local-moving kernel for a weighted, undirected graph. Store each edge weight once, but include both directions in the adjacency list, as the examples do. The crucial detail is to remove a vertex from its old community before comparing insertion scores—including the score for returning to the old community.

def louvain_phase1(n, adj, weights, m, gamma=1.0, tol=1e-12):
    """
    Simplified single-level Louvain local-moving phase (Phase 1 only, no aggregation).
    n: vertices. adj[v]: list of neighbors. weights[(u,v)]: edge weight. m: total edge weight.
    weights contains each undirected edge once as (min(u,v), max(u,v)).
    Returns a community assignment for this level.
    """
    community = list(range(n))  # start with every vertex in its own community
    if m <= 0:
        return community
    degree = [sum(weights.get(tuple(sorted((v, u))), 1) for u in adj[v]) for v in range(n)]
    community_degree = degree[:]

    improved = True
    while improved:
        improved = False
        for v in range(n):
            current_community = community[v]

            # Tentatively remove v from its current community
            community_degree[current_community] -= degree[v]

            neighbor_communities = {}
            for u in adj[v]:
                w = weights.get(tuple(sorted((v, u))), 1)
                neighbor_communities[community[u]] = neighbor_communities.get(community[u], 0) + w

            # Compare insertion scores after removal. Returning to the old
            # community is a candidate, so a move must beat that score.
            old_attachment = neighbor_communities.get(current_community, 0.0)
            best_gain = old_attachment - gamma * (
                community_degree[current_community] * degree[v]
            ) / (2 * m)
            best_community = current_community

            for c, edge_weight_to_c in neighbor_communities.items():
                gain = edge_weight_to_c - gamma * (community_degree[c] * degree[v]) / (2 * m)
                if gain > best_gain + tol:
                    best_gain, best_community = gain, c

            community_degree[best_community] += degree[v]
            if best_community != current_community:
                improved = True
            community[v] = best_community

    return community

adj = [[1,2],[0,2],[0,1,3],[2,4,5],[3,5],[3,4]]
weights = {(0,1):1,(0,2):1,(1,2):1,(2,3):1,(3,4):1,(3,5):1,(4,5):1}
m = sum(weights.values())
print(louvain_phase1(6, adj, weights, m))  # IDs are arbitrary labels
// Simplified single-level Louvain local-moving phase (Phase 1 only).
#include <vector>
#include <map>
#include <iostream>
#include <algorithm>
using namespace std;

vector<int> louvainPhase1(int n, vector<vector<int>>& adj,
                         map<pair<int,int>,double>& weights,
                         double m, double gamma = 1.0) {
    vector<int> community(n);
    for (int i = 0; i < n; i++) community[i] = i;
    if (m <= 0) return community;

    vector<double> degree(n, 0);
    for (int v = 0; v < n; v++)
        for (int u : adj[v]) degree[v] += weights[minmax(v, u)];

    vector<double> communityDegree = degree;

    bool improved = true;
    while (improved) {
        improved = false;
        for (int v = 0; v < n; v++) {
            int currentCommunity = community[v];
            communityDegree[currentCommunity] -= degree[v];

            map<int, double> neighborCommunities;
            for (int u : adj[v]) {
                double w = weights[minmax(v, u)];
                neighborCommunities[community[u]] += w;
            }

            double oldAttachment = neighborCommunities[currentCommunity];
            double bestGain = oldAttachment
                - gamma * communityDegree[currentCommunity] * degree[v] / (2 * m);
            int bestCommunity = currentCommunity;
            for (auto& [c, edgeWeightToC] : neighborCommunities) {
                double gain = edgeWeightToC
                    - gamma * communityDegree[c] * degree[v] / (2 * m);
                if (gain > bestGain + 1e-12) {
                    bestGain = gain; bestCommunity = c;
                }
            }

            communityDegree[bestCommunity] += degree[v];
            if (bestCommunity != currentCommunity) improved = true;
            community[v] = bestCommunity;
        }
    }
    return community;
}

int main() {
    int n = 6;
    vector<vector<int>> adj = {{1,2},{0,2},{0,1,3},{2,4,5},{3,5},{3,4}};
    map<pair<int,int>,double> weights = {
        {{0,1},1},{{0,2},1},{{1,2},1},{{2,3},1},{{3,4},1},{{3,5},1},{{4,5},1}
    };
    double m = 7;
    auto result = louvainPhase1(n, adj, weights, m);
    for (int c : result) cout << c << " ";
    return 0;
}
import java.util.*;

class Louvain {
    static int[] phase1(int n, List<List<Integer>> adj,
                        Map<List<Integer>, Double> weights,
                        double m, double gamma) {
        int[] community = new int[n];
        for (int i = 0; i < n; i++) community[i] = i;
        if (m <= 0) return community;

        double[] degree = new double[n];
        for (int v = 0; v < n; v++)
            for (int u : adj.get(v)) degree[v] += weights.get(Arrays.asList(Math.min(v,u), Math.max(v,u)));

        double[] communityDegree = degree.clone();

        boolean improved = true;
        while (improved) {
            improved = false;
            for (int v = 0; v < n; v++) {
                int currentCommunity = community[v];
                communityDegree[currentCommunity] -= degree[v];

                Map<Integer, Double> neighborCommunities = new HashMap<>();
                for (int u : adj.get(v)) {
                    double w = weights.get(Arrays.asList(Math.min(v,u), Math.max(v,u)));
                    neighborCommunities.merge(community[u], w, Double::sum);
                }

                double oldAttachment = neighborCommunities.getOrDefault(currentCommunity, 0.0);
                double bestGain = oldAttachment
                    - gamma * communityDegree[currentCommunity] * degree[v] / (2 * m);
                int bestCommunity = currentCommunity;
                for (var entry : neighborCommunities.entrySet()) {
                    int c = entry.getKey();
                    double gain = entry.getValue()
                        - gamma * communityDegree[c] * degree[v] / (2 * m);
                    if (gain > bestGain + 1e-12) {
                        bestGain = gain; bestCommunity = c;
                    }
                }

                communityDegree[bestCommunity] += degree[v];
                if (bestCommunity != currentCommunity) improved = true;
                community[v] = bestCommunity;
            }
        }
        return community;
    }

    public static void main(String[] args) {
        int n = 6;
        List<List<Integer>> adj = Arrays.asList(
            Arrays.asList(1,2), Arrays.asList(0,2), Arrays.asList(0,1,3),
            Arrays.asList(2,4,5), Arrays.asList(3,5), Arrays.asList(3,4)
        );
        Map<List<Integer>, Double> weights = new HashMap<>();
        int[][] edges = {{0,1},{0,2},{1,2},{2,3},{3,4},{3,5},{4,5}};
        for (int[] e : edges) weights.put(Arrays.asList(e[0], e[1]), 1.0);
        System.out.println(Arrays.toString(phase1(n, adj, weights, 7, 1.0)));
    }
}

Contraction pseudocode

def contract(edges, community):
    coarse_weight = defaultdict(float)
    for u, v, w in edges:                 # each undirected edge appears once
        a, b = community[u], community[v]
        key = (min(a, b), max(a, b))
        coarse_weight[key] += w           # a == b becomes a self-loop
    return [(a, b, w) for (a, b), w in coarse_weight.items()]

# Repeat:
# labels = louvain_phase1(coarse_graph)
# coarse_graph = contract(coarse_graph, labels)
# compose labels with the original-vertex mapping

The contraction step sums parallel edges instead of discarding them. That is what makes the coarse graph encode exactly how strongly the discovered communities interact.

Complexity Analysis

With cached vertex strengths, community volumes, and edge weight from a vertex into neighboring communities, one full sweep is proportional to the adjacency entries examined:

$$T_{\mathrm{sweep}}=O(E), \qquad S=O(V+E)$$

If a level uses $s_\ell$ sweeps and the contracted graph at that level has $E_\ell$ edges, a more honest total is $O(\sum_\ell s_\ell E_\ell)$. The graph usually shrinks quickly and only a few sweeps are needed, so Louvain is often described as near-linear in practice. That phrase is empirical—not a universal worst-case guarantee.

Why it is fast

A candidate move does not recompute $Q$ from scratch. It needs only the vertex strength, candidate-community volume, and weight from the vertex into that community. Contraction then reduces the next level’s problem size.

Choosing a Community Method

MethodMain ideaStrengthWatch for
LouvainGreedy modularity moves + contractionVery fast; naturally multilevelOrder dependence; disconnected or weakly connected groups can occur
LeidenLouvain plus partition refinementBetter-connected communities; strong default for modularity-style workStill depends on objective, resolution, and random seed
Girvan–NewmanRemove high-betweenness edgesIntuitive dendrogram on small graphsFar too expensive for large networks
Label propagationAdopt neighbors’ dominant labelExtremely simple and fastUnstable outcomes and limited objective control
Spectral partitioningUse Laplacian eigenvectorsStrong mathematical foundationEigenvector computation and choice of cut/cluster count

Real-World Applications

Social networks

Discover circles of interaction, interest groups, or organizational substructures from weighted relationships.

Biological networks

Find modules in protein-interaction, metabolic, ecological, or gene co-expression graphs.

Behavior graphs

Group products, sessions, or accounts connected by co-purchase, co-view, or transaction patterns.

In every domain, a detected group is a hypothesis generated from the graph and objective. Validate it with domain labels, temporal stability, held-out behavior, or downstream usefulness; high modularity alone does not establish a real-world category.

Exercises

Build the intuition

  1. Draw two triangles joined by one bridge. Starting from singleton communities, identify which candidate communities each bridge endpoint must evaluate.
  2. Explain why moving a vertex into the largest neighboring community is not always the best modularity move.
  3. Contract your two triangles. What becomes a self-loop, and what becomes the edge between the two super-nodes?
  4. Predict how increasing $\gamma$ affects community size. Then test several values in a library implementation.
  5. Challenge: run multiple random seeds, align the resulting labels, and calculate how often each pair of vertices appears in the same community.

Hint: community IDs such as 0 and 4 have no semantic meaning. Compare sets of vertices or a co-assignment matrix, not the raw numeric labels.

Limitations

What a high modularity score does not guarantee

  • No unique truth: many very different partitions can have nearly identical modularity.
  • Resolution limit: small genuine groups may be merged in a large network; tuning $\gamma$ changes but does not magically remove model dependence.
  • Local optimum: vertex order, seed, and ties can lead to different final partitions.
  • Connectivity: Louvain may output communities that are internally disconnected or only weakly connected; Leiden is often preferred when this matters.
  • Static snapshot: temporal, overlapping, directed, signed, or multilayer networks may need objectives designed for those structures.

Report the graph construction, edge-weight meaning, objective, resolution, seed policy, number of runs, selected level, and stability checks. Community detection is most useful when its modeling choices are visible.