@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
573 lines (488 loc) • 17 kB
text/typescript
/**
* Leiden Algorithm for Community Detection
*
* An improved version of the Louvain algorithm that guarantees
* well-connected communities and provides better quality partitions.
*
* Reference: Traag, V.A., Waltman, L. & van Eck, N.J. (2019)
* "From Louvain to Leiden: guaranteeing well-connected communities"
*/
// Louvain import removed - not used in this implementation
export interface LeidenOptions {
resolution?: number;
randomSeed?: number;
maxIterations?: number;
threshold?: number;
}
export interface LeidenResult {
communities: Map<string, number>;
modularity: number;
iterations: number;
}
/**
* Leiden algorithm for community detection
* Improves upon Louvain by ensuring well-connected communities
*
* @param graph - Undirected weighted graph
* @param options - Algorithm options
* @returns Community assignments and modularity
*
* Time Complexity: O(m) per iteration, typically O(m log m) total
* Space Complexity: O(n + m)
*/
export function leiden(
graph: Map<string, Map<string, number>>,
options: LeidenOptions = {},
): LeidenResult {
const {
resolution = 1.0,
randomSeed = 42,
maxIterations = 100,
threshold = 1e-7,
} = options;
// Handle empty graph
if (graph.size === 0) {
return {
communities: new Map(),
modularity: 0,
iterations: 0,
};
}
// Initialize random number generator
let seed = randomSeed;
const random = (): number => {
seed = ((seed * 1664525) + 1013904223) % 2147483647;
return seed / 2147483647;
};
// Calculate total weight
let totalWeight = 0;
const degrees = new Map<string, number>();
for (const [node, neighbors] of graph) {
let degree = 0;
for (const weight of neighbors.values()) {
degree += weight;
totalWeight += weight;
}
degrees.set(node, degree);
}
totalWeight /= 2; // Each edge counted twice
// Initialize communities - each node in its own community
const communities = new Map<string, number>();
const nodes = Array.from(graph.keys());
nodes.forEach((node, i) => communities.set(node, i));
let modularity = calculateModularity(graph, communities, degrees, totalWeight, resolution);
let bestModularity = modularity;
let bestCommunities = new Map(communities);
let iterations = 0;
// Main Leiden loop
while (iterations < maxIterations) {
iterations++;
let improved = false;
// Phase 1: Local moving of nodes (fast)
const nodeOrder = [... nodes];
shuffle(nodeOrder, random);
for (const node of nodeOrder) {
const currentCommunity = communities.get(node);
if (currentCommunity === undefined) {
continue;
}
const neighborCommunities = getNeighborCommunities(node, graph, communities);
let bestCommunity = currentCommunity;
let bestGain = 0;
// Try moving to each neighbor community
for (const [community] of neighborCommunities) {
if (community === currentCommunity) {
continue;
}
const gain = calculateModularityGain(
node, community, graph, communities, degrees, totalWeight, resolution,
);
if (gain > bestGain) {
bestGain = gain;
bestCommunity = community;
}
}
// Move node if beneficial
if (bestCommunity !== currentCommunity) {
communities.set(node, bestCommunity);
modularity += bestGain;
improved = true;
}
}
// Phase 2: Refinement (Leiden improvement over Louvain)
// Create aggregate network based on current partition
createAggregateNetwork(graph, communities);
// Refine partition using aggregate network
const subsetPartition = refinePartition(
graph, communities,
);
// Apply refined partition
for (const [node, newCommunity] of subsetPartition) {
communities.set(node, newCommunity);
}
// Recalculate modularity
modularity = calculateModularity(graph, communities, degrees, totalWeight, resolution);
// Check if we've improved
if (modularity > bestModularity + threshold) {
bestModularity = modularity;
bestCommunities = new Map(communities);
improved = true;
}
if (!improved) {
break;
}
// Phase 3: Aggregate network (create super-nodes)
const aggregated = aggregateCommunities(graph, communities);
if (aggregated.graph.size === graph.size) {
break;
} // No aggregation possible
// Continue with aggregated network
const {graph: newGraph} = aggregated;
graph = newGraph;
communities.clear();
let communityId = 0;
for (const node of graph.keys()) {
communities.set(node, communityId++);
}
}
// Map back to original nodes
const finalCommunities = new Map<string, number>();
for (const [node, community] of bestCommunities) {
finalCommunities.set(node, community);
}
// Renumber communities consecutively
const communityRenumber = new Map<number, number>();
let newId = 0;
for (const community of new Set(finalCommunities.values())) {
communityRenumber.set(community, newId++);
}
for (const [node, community] of finalCommunities) {
const newCommunityId = communityRenumber.get(community);
if (newCommunityId !== undefined) {
finalCommunities.set(node, newCommunityId);
}
}
return {
communities: finalCommunities,
modularity: bestModularity,
iterations,
};
}
/**
* Calculate modularity of a partition
*/
function calculateModularity(
graph: Map<string, Map<string, number>>,
communities: Map<string, number>,
degrees: Map<string, number>,
totalWeight: number,
resolution: number,
): number {
let modularity = 0;
const communityWeights = new Map<number, number>();
// Calculate internal weights for each community
for (const [node, neighbors] of graph) {
const nodeCommunity = communities.get(node);
if (nodeCommunity === undefined) {
continue;
}
for (const [neighbor, weight] of neighbors) {
const neighborCommunity = communities.get(neighbor);
if (neighborCommunity === undefined) {
continue;
}
if (nodeCommunity === neighborCommunity) {
modularity += weight;
}
}
const degree = degrees.get(node);
if (degree !== undefined) {
communityWeights.set(nodeCommunity, (communityWeights.get(nodeCommunity) ?? 0) + degree);
}
}
// Handle empty graph or zero weight
if (totalWeight === 0) {
return 0;
}
// Normalize and apply resolution
modularity /= (2 * totalWeight);
// Subtract expected edges
for (const weight of communityWeights.values()) {
modularity -= resolution * ((weight / (2 * totalWeight)) ** 2);
}
return modularity;
}
/**
* Get communities of neighbors
*/
function getNeighborCommunities(
node: string,
graph: Map<string, Map<string, number>>,
communities: Map<string, number>,
): Map<number, number> {
const neighborCommunities = new Map<number, number>();
const neighbors = graph.get(node);
if (neighbors) {
for (const [neighbor, weight] of neighbors) {
const community = communities.get(neighbor);
if (community !== undefined) {
neighborCommunities.set(community, (neighborCommunities.get(community) ?? 0) + weight);
}
}
}
return neighborCommunities;
}
/**
* Calculate modularity gain from moving a node to a community
*/
function calculateModularityGain(
node: string,
targetCommunity: number,
graph: Map<string, Map<string, number>>,
communities: Map<string, number>,
degrees: Map<string, number>,
totalWeight: number,
resolution: number,
): number {
const currentCommunity = communities.get(node);
const nodeDegree = degrees.get(node);
if (currentCommunity === undefined || nodeDegree === undefined) {
return 0;
}
// Weight of edges from node to target community
let weightToTarget = 0;
let weightToCurrent = 0;
const neighbors = graph.get(node);
if (neighbors) {
for (const [neighbor, weight] of neighbors) {
const neighborCommunity = communities.get(neighbor);
if (neighborCommunity === undefined) {
continue;
}
if (neighborCommunity === targetCommunity) {
weightToTarget += weight;
} else if (neighborCommunity === currentCommunity && neighbor !== node) {
weightToCurrent += weight;
}
}
}
// Calculate community degrees
let targetDegree = 0;
let currentDegree = 0;
for (const [n, c] of communities) {
if (c === targetCommunity && n !== node) {
const deg = degrees.get(n);
if (deg !== undefined) {
targetDegree += deg;
}
} else if (c === currentCommunity && n !== node) {
const deg = degrees.get(n);
if (deg !== undefined) {
currentDegree += deg;
}
}
}
// Modularity gain calculation
const m2 = 2 * totalWeight;
const gain = ((weightToTarget - weightToCurrent) / totalWeight) -
(resolution * nodeDegree * (targetDegree - currentDegree) / (m2 * m2));
return gain;
}
/**
* Create aggregate network where each community becomes a super-node
*/
function createAggregateNetwork(
graph: Map<string, Map<string, number>>,
communities: Map<string, number>,
): {
aggregateGraph: Map<number, Map<number, number>>;
nodeMapping: Map<string, number>;
} {
const aggregateGraph = new Map<number, Map<number, number>>();
const nodeMapping = new Map<string, number>();
// Create mapping from nodes to communities
for (const [node, community] of communities) {
nodeMapping.set(node, community);
if (!aggregateGraph.has(community)) {
aggregateGraph.set(community, new Map());
}
}
// Aggregate edges
for (const [node, neighbors] of graph) {
const sourceCommunity = communities.get(node);
if (sourceCommunity === undefined) {
continue;
}
for (const [neighbor, weight] of neighbors) {
const targetCommunity = communities.get(neighbor);
if (targetCommunity === undefined) {
continue;
}
const sourceNeighbors = aggregateGraph.get(sourceCommunity);
if (sourceNeighbors) {
const current = sourceNeighbors.get(targetCommunity) ?? 0;
sourceNeighbors.set(targetCommunity, current + weight);
}
}
}
return {aggregateGraph, nodeMapping};
}
/**
* Refine partition (Leiden-specific improvement)
* Ensures well-connected communities by considering subsets
*/
function refinePartition(
originalGraph: Map<string, Map<string, number>>,
communities: Map<string, number>,
): Map<string, number> {
const refined = new Map<string, number>();
// For each community, check if it should be split
const communityNodes = new Map<number, string[]>();
for (const [node, community] of communities) {
if (!communityNodes.has(community)) {
communityNodes.set(community, []);
}
const nodes = communityNodes.get(community);
if (nodes) {
nodes.push(node);
}
}
let newCommunityId = 0;
for (const [community, nodes] of communityNodes) {
if (nodes.length === 1) {
// Single node community
const singleNode = nodes[0];
if (singleNode) {
refined.set(singleNode, newCommunityId++);
}
continue;
}
// Check connectivity within community
const subgraph = new Map<string, Set<string>>();
for (const node of nodes) {
subgraph.set(node, new Set());
const neighbors = originalGraph.get(node);
if (neighbors) {
for (const [neighbor] of neighbors) {
if (communities.get(neighbor) === community) {
const nodeSet = subgraph.get(node);
if (nodeSet) {
nodeSet.add(neighbor);
}
}
}
}
}
// Find connected components within community
const components = findConnectedComponents(subgraph);
// Assign new community IDs to components
for (const component of components) {
for (const node of component) {
refined.set(node, newCommunityId);
}
newCommunityId++;
}
}
return refined;
}
/**
* Find connected components in undirected graph
*/
function findConnectedComponents(graph: Map<string, Set<string>>): Set<string>[] {
const visited = new Set<string>();
const components: Set<string>[] = [];
for (const node of graph.keys()) {
if (!visited.has(node)) {
const component = new Set<string>();
const queue = [node];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined || visited.has(current)) {
continue;
}
visited.add(current);
component.add(current);
const neighbors = graph.get(current);
if (neighbors) {
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
queue.push(neighbor);
}
}
}
}
components.push(component);
}
}
return components;
}
/**
* Aggregate communities into super-nodes
*/
function aggregateCommunities(
graph: Map<string, Map<string, number>>,
communities: Map<string, number>,
): {
graph: Map<string, Map<string, number>>;
mapping: Map<string, string>;
} {
const aggregated = new Map<string, Map<string, number>>();
const mapping = new Map<string, string>();
// Create super-nodes
const communityNodes = new Map<number, string>();
for (const community of new Set(communities.values())) {
const superNode = `super_${String(community)}`;
communityNodes.set(community, superNode);
aggregated.set(superNode, new Map());
}
// Map original nodes to super-nodes
for (const [node, community] of communities) {
const superNode = communityNodes.get(community);
if (superNode !== undefined) {
mapping.set(node, superNode);
}
}
// Aggregate edges
for (const [node, neighbors] of graph) {
const sourceCommunity = communities.get(node);
if (sourceCommunity === undefined) {
continue;
}
const sourceSuper = communityNodes.get(sourceCommunity);
if (sourceSuper === undefined) {
continue;
}
for (const [neighbor, weight] of neighbors) {
const targetCommunity = communities.get(neighbor);
if (targetCommunity === undefined) {
continue;
}
const targetSuper = communityNodes.get(targetCommunity);
if (targetSuper === undefined) {
continue;
}
if (sourceSuper !== targetSuper) {
const sourceNeighbors = aggregated.get(sourceSuper);
if (sourceNeighbors) {
const current = sourceNeighbors.get(targetSuper) ?? 0;
sourceNeighbors.set(targetSuper, current + weight);
}
}
}
}
return {graph: aggregated, mapping};
}
/**
* Fisher-Yates shuffle
*/
function shuffle(array: unknown[], random: () => number): void {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
const temp = array[i];
const swapItem = array[j];
if (temp !== undefined && swapItem !== undefined) {
array[i] = swapItem;
array[j] = temp;
}
}
}