@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
446 lines (379 loc) • 14.8 kB
text/typescript
import type {Graph} from "../../core/graph.js";
import type {CommunityResult, NodeId} from "../../types/index.js";
/**
* Optimized Louvain community detection algorithm with early pruning and threshold cycling
*
* Key optimizations:
* - Leaf node pruning: Skip nodes with degree 1
* - Importance ordering: Process high-impact nodes first
* - Threshold cycling: Adaptive convergence thresholds
* - Early termination: Stop when changes become insignificant
*
* Expected speedup: 2-5x on large graphs with many leaf nodes
*/
export interface OptimizedLouvainOptions {
/**
* Resolution parameter (default: 1.0)
*/
resolution?: number;
/**
* Maximum iterations per level (default: 100)
*/
maxIterations?: number;
/**
* Convergence tolerance (default: 1e-6)
*/
tolerance?: number;
/**
* Enable leaf node pruning (default: true)
*/
pruneLeaves?: boolean;
/**
* Enable importance-based node ordering (default: true)
*/
importanceOrdering?: boolean;
/**
* Base pruning threshold (default: 0.01)
*/
pruningThreshold?: number;
/**
* Enable adaptive threshold cycling (default: true)
*/
thresholdCycling?: boolean;
}
interface PruningStats {
leafNodesPruned: number;
lowDegreeNodesPruned: number;
stableNodesPruned: number;
}
export class OptimizedLouvain {
private graph: Graph;
private communities: Map<NodeId, number>;
private communityWeights: Map<number, number>;
private nodeWeights: Map<NodeId, number>;
private nodeDegrees: Map<NodeId, number>;
private totalWeight: number;
private pruningStats: PruningStats;
constructor(graph: Graph) {
this.graph = graph;
this.communities = new Map();
this.communityWeights = new Map();
this.nodeWeights = new Map();
this.nodeDegrees = new Map();
this.totalWeight = 0;
this.pruningStats = {
leafNodesPruned: 0,
lowDegreeNodesPruned: 0,
stableNodesPruned: 0,
};
}
/**
* Run optimized Louvain algorithm
*/
public detectCommunities(options: OptimizedLouvainOptions = {}): CommunityResult {
const {
resolution = 1.0,
maxIterations = 100,
tolerance = 1e-6,
pruneLeaves = true,
importanceOrdering = true,
pruningThreshold = 0.01,
thresholdCycling = true,
} = options;
// Initialize
this.initialize();
let modularity = this.calculateModularity(resolution);
let iteration = 0;
let improved = true;
while (iteration < maxIterations && improved) {
// Get nodes in optimal processing order
const orderedNodes = importanceOrdering ?
this.getNodesInImportanceOrder() :
Array.from(this.graph.nodes()).map((n) => n.id);
// Apply adaptive threshold
const threshold = thresholdCycling ?
this.getAdaptiveThreshold(iteration, pruningThreshold) :
0;
// Perform local optimization
improved = this.performLocalMoving(orderedNodes, {
pruneLeaves,
threshold,
resolution,
});
if (improved) {
const newModularity = this.calculateModularity(resolution);
// Check convergence
if (Math.abs(newModularity - modularity) < tolerance) {
break;
}
modularity = newModularity;
iteration++;
}
}
// Convert community assignments to result format
const communityGroups = new Map<number, NodeId[]>();
for (const [nodeId, community] of this.communities) {
if (!communityGroups.has(community)) {
communityGroups.set(community, []);
}
const group = communityGroups.get(community);
if (group) {
group.push(nodeId);
}
}
return {
communities: Array.from(communityGroups.values()),
modularity,
iterations: iteration,
};
}
/**
* Initialize data structures
*/
private initialize(): void {
let communityId = 0;
// Initialize each node in its own community
for (const node of this.graph.nodes()) {
this.communities.set(node.id, communityId);
// Calculate node weight and degree
let nodeWeight = 0;
let degree = 0;
for (const neighbor of Array.from(this.graph.neighbors(node.id))) {
const edge = this.graph.getEdge(node.id, neighbor);
const weight = edge?.weight ?? 1;
nodeWeight += weight;
degree++;
}
// For undirected graphs, also check incoming edges
if (!this.graph.isDirected) {
for (const neighbor of Array.from(this.graph.inNeighbors(node.id))) {
if (!this.graph.hasEdge(node.id, neighbor)) {
const edge = this.graph.getEdge(neighbor, node.id);
const weight = edge?.weight ?? 1;
nodeWeight += weight;
degree++;
}
}
}
this.nodeWeights.set(node.id, nodeWeight);
this.nodeDegrees.set(node.id, degree);
this.communityWeights.set(communityId, nodeWeight);
this.totalWeight += nodeWeight;
communityId++;
}
// Total weight is sum of all edge weights
// For undirected graphs, each edge is counted twice from both endpoints
this.totalWeight = this.totalWeight / 2;
}
/**
* Get nodes ordered by importance (degree * log(weight))
*/
private getNodesInImportanceOrder(): NodeId[] {
const nodeImportance = new Map<NodeId, number>();
for (const [nodeId, degree] of this.nodeDegrees) {
const weight = this.nodeWeights.get(nodeId) ?? 0;
// Importance score: combination of degree and weight
// High-degree nodes and nodes with heavy edges are processed first
const importance = degree * Math.log(1 + weight);
nodeImportance.set(nodeId, importance);
}
// Sort by importance (descending)
return Array.from(nodeImportance.entries())
.sort((a, b) => b[1] - a[1])
.map(([nodeId]) => nodeId);
}
/**
* Perform local moving phase with optimizations
*/
private performLocalMoving(
nodes: NodeId[],
options: {
pruneLeaves: boolean;
threshold: number;
resolution: number;
},
): boolean {
const {pruneLeaves, threshold, resolution} = options;
let improvement = false;
let hasChanged = true;
while (hasChanged) {
hasChanged = false;
for (const nodeId of nodes) {
// Early pruning: skip leaf nodes
if (pruneLeaves && this.isLeafNode(nodeId)) {
this.pruningStats.leafNodesPruned++;
continue;
}
const currentCommunity = this.communities.get(nodeId) ?? 0;
const neighborCommunities = this.getNeighborCommunities(nodeId);
// Skip isolated nodes
if (neighborCommunities.size === 0) {
continue;
}
// Find best community to move to
let bestCommunity = currentCommunity;
let bestGain = 0;
// Remove node from its current community to calculate gains
this.removeNodeFromCommunity(nodeId, currentCommunity);
for (const community of neighborCommunities) {
const gain = this.calculateModularityGain(nodeId, community, resolution);
// Apply threshold - only move if gain exceeds threshold
if (gain > bestGain + threshold) {
bestGain = gain;
bestCommunity = community;
}
}
// Try staying in current community
const currentGain = this.calculateModularityGain(nodeId, currentCommunity, resolution);
if (currentGain > bestGain + threshold) {
bestGain = currentGain;
bestCommunity = currentCommunity;
}
// Add node to best community
this.addNodeToCommunity(nodeId, bestCommunity);
// Track if node moved
if (bestCommunity !== currentCommunity) {
hasChanged = true;
improvement = true;
}
}
}
return improvement;
}
/**
* Check if node is a leaf (degree 1)
*/
private isLeafNode(nodeId: NodeId): boolean {
const degree = this.nodeDegrees.get(nodeId) ?? 0;
return degree === 1;
}
/**
* Get adaptive threshold that decreases with iterations
*/
private getAdaptiveThreshold(iteration: number, baseThreshold: number): number {
// Exponentially decay threshold with iterations
// This allows coarse movements early and fine-tuning later
return baseThreshold * Math.pow(0.5, iteration / 10);
}
/**
* Calculate modularity gain from moving a node to a community
*/
private calculateModularityGain(
nodeId: NodeId,
targetCommunity: number,
resolution: number,
): number {
const nodeWeight = this.nodeWeights.get(nodeId) ?? 0;
// Sum of weights from node to target community
let weightToTarget = 0;
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
if (this.communities.get(neighbor) === targetCommunity) {
const edge = this.graph.getEdge(nodeId, neighbor);
weightToTarget += edge?.weight ?? 1;
}
}
// For undirected graphs, also check incoming edges
if (!this.graph.isDirected) {
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
if (this.communities.get(neighbor) === targetCommunity && !this.graph.hasEdge(nodeId, neighbor)) {
const edge = this.graph.getEdge(neighbor, nodeId);
weightToTarget += edge?.weight ?? 1;
}
}
}
// Weight of target community
const targetWeight = this.communityWeights.get(targetCommunity) ?? 0;
// Modularity gain formula
const gain = (weightToTarget - ((resolution * nodeWeight * targetWeight) / (2 * this.totalWeight))) / this.totalWeight;
return gain;
}
/**
* Remove node from community (for gain calculation)
*/
private removeNodeFromCommunity(nodeId: NodeId, community: number): void {
const nodeWeight = this.nodeWeights.get(nodeId) ?? 0;
this.communityWeights.set(community, (this.communityWeights.get(community) ?? 0) - nodeWeight);
this.communities.delete(nodeId);
}
/**
* Add node to community
*/
private addNodeToCommunity(nodeId: NodeId, community: number): void {
const nodeWeight = this.nodeWeights.get(nodeId) ?? 0;
this.communityWeights.set(community, (this.communityWeights.get(community) ?? 0) + nodeWeight);
this.communities.set(nodeId, community);
}
/**
* Get neighboring communities of a node
*/
private getNeighborCommunities(nodeId: NodeId): Set<number> {
const communities = new Set<number>();
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
const community = this.communities.get(neighbor);
if (community !== undefined) {
communities.add(community);
}
}
// For undirected graphs, also check incoming edges
if (!this.graph.isDirected) {
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
const community = this.communities.get(neighbor);
if (community !== undefined) {
communities.add(community);
}
}
}
return communities;
}
/**
* Calculate total modularity
*/
private calculateModularity(resolution: number): number {
if (this.totalWeight === 0) {
return 0;
}
let modularity = 0;
// Sum over all communities
const communityInternalWeights = new Map<number, number>();
// Calculate internal weights for each community
for (const node of this.graph.nodes()) {
const nodeId = node.id;
const community = this.communities.get(nodeId) ?? 0;
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
if (this.communities.get(neighbor) === community) {
const edge = this.graph.getEdge(nodeId, neighbor);
const weight = edge?.weight ?? 1;
communityInternalWeights.set(community, (communityInternalWeights.get(community) ?? 0) + weight);
}
}
}
// Calculate modularity
for (const [community, internalWeight] of communityInternalWeights) {
const communityWeight = this.communityWeights.get(community) ?? 0;
// For undirected graphs, internal weights are counted twice
const aIn = this.graph.isDirected ? internalWeight : internalWeight / 2;
const aTotal = communityWeight;
// Modularity formula: sum of (fraction of edges within community - expected fraction)
const actualFraction = aIn / this.totalWeight;
const expectedFraction = resolution * Math.pow(aTotal / (2 * this.totalWeight), 2);
modularity += actualFraction - expectedFraction;
}
return modularity;
}
/**
* Get pruning statistics
*/
public getPruningStats(): PruningStats {
return {... this.pruningStats};
}
}
/**
* Optimized Louvain algorithm with automatic optimization selection
*/
export function louvainOptimized(
graph: Graph,
options: OptimizedLouvainOptions = {},
): CommunityResult {
const optimizer = new OptimizedLouvain(graph);
return optimizer.detectCommunities(options);
}