@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
303 lines • 12.4 kB
JavaScript
export class OptimizedLouvain {
constructor(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
*/
detectCommunities(options = {}) {
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();
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
*/
initialize() {
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))
*/
getNodesInImportanceOrder() {
const nodeImportance = new Map();
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
*/
performLocalMoving(nodes, options) {
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)
*/
isLeafNode(nodeId) {
const degree = this.nodeDegrees.get(nodeId) ?? 0;
return degree === 1;
}
/**
* Get adaptive threshold that decreases with iterations
*/
getAdaptiveThreshold(iteration, baseThreshold) {
// 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
*/
calculateModularityGain(nodeId, targetCommunity, resolution) {
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)
*/
removeNodeFromCommunity(nodeId, community) {
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
*/
addNodeToCommunity(nodeId, community) {
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
*/
getNeighborCommunities(nodeId) {
const communities = new Set();
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
*/
calculateModularity(resolution) {
if (this.totalWeight === 0) {
return 0;
}
let modularity = 0;
// Sum over all communities
const communityInternalWeights = new Map();
// 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
*/
getPruningStats() {
return { ...this.pruningStats };
}
}
/**
* Optimized Louvain algorithm with automatic optimization selection
*/
export function louvainOptimized(graph, options = {}) {
const optimizer = new OptimizedLouvain(graph);
return optimizer.detectCommunities(options);
}
//# sourceMappingURL=louvain-optimized.js.map