@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
373 lines (308 loc) • 12.1 kB
text/typescript
import {Graph} from "../../core/graph.js";
import type {CommunityResult, ComponentResult, GirvanNewmanOptions, NodeId} from "../../types/index.js";
import {connectedComponents} from "../components/connected.js";
/**
* Girvan-Newman community detection algorithm
*
* Implements the Girvan-Newman method for community detection by iteratively
* removing edges with the highest betweenness centrality until the graph
* splits into disconnected components.
*
* This is a divisive hierarchical clustering algorithm that produces a
* dendrogram of community structures.
*
* References:
* - Girvan, M., & Newman, M. E. J. (2002). Community structure in social
* and biological networks. Proceedings of the National Academy of Sciences,
* 99(12), 7821-7826.
*
* @param graph - The input graph
* @param options - Algorithm options
* @returns Array of community detection results representing the dendrogram
*/
export function girvanNewman(
graph: Graph,
options: GirvanNewmanOptions = {},
): CommunityResult[] {
const {maxCommunities} = options;
const minCommunitySize = options.minCommunitySize ?? 1;
const dendrogram: CommunityResult[] = [];
const workingGraph = cloneGraph(graph);
// Initial state: one large component
let components = getConnectedComponentsResult(workingGraph);
dendrogram.push({
communities: components.components.filter((community) => community.length >= minCommunitySize),
modularity: calculateModularity(graph, components.componentMap),
});
while (Array.from(workingGraph.edges()).length > 0) {
// Calculate edge betweenness centrality
const edgeBetweenness = calculateEdgeBetweenness(workingGraph);
if (edgeBetweenness.size === 0) {
break;
}
// Find edges with maximum betweenness
const maxBetweenness = Math.max(... edgeBetweenness.values());
const edgesToRemove: {source: NodeId, target: NodeId}[] = [];
for (const [edgeKey, centrality] of edgeBetweenness) {
if (Math.abs(centrality - maxBetweenness) < 1e-10) {
const [source, target] = edgeKey.split("|");
if (source && target) {
edgesToRemove.push({source, target});
}
}
}
// Remove edges with highest betweenness
for (const {source, target} of edgesToRemove) {
workingGraph.removeEdge(source, target);
}
// Find new connected components (communities)
components = getConnectedComponentsResult(workingGraph);
// Filter communities by minimum size
const validCommunities = components.components.filter(
(community) => community.length >= minCommunitySize,
);
// Calculate modularity for the new community structure
const modularity = calculateModularity(graph, components.componentMap);
dendrogram.push({
communities: validCommunities,
modularity,
});
// Stop if desired number of communities reached
if (maxCommunities && validCommunities.length >= maxCommunities) {
break;
}
// Stop if no more meaningful communities can be formed
if (validCommunities.length === workingGraph.nodeCount) {
break;
}
}
return dendrogram;
}
/**
* Calculate edge betweenness centrality for all edges in the graph
*
* Edge betweenness is the fraction of shortest paths that pass through the edge.
* We adapt node betweenness centrality calculation to work with edges.
*/
function calculateEdgeBetweenness(graph: Graph): Map<string, number> {
const edgeBetweenness = new Map<string, number>();
// Initialize all edges with 0 betweenness
for (const edge of graph.edges()) {
const edgeKey = getEdgeKey(edge.source, edge.target);
edgeBetweenness.set(edgeKey, 0);
}
// For each node as source, calculate shortest paths and accumulate edge betweenness
for (const sourceNode of graph.nodes()) {
const source = sourceNode.id;
// Run BFS to find all shortest paths from source
const {distances, predecessors, pathCounts} = findAllShortestPaths(graph, source);
// Calculate dependency for each node and accumulate edge betweenness
const dependency = new Map<NodeId, number>();
// Initialize dependency
for (const node of graph.nodes()) {
dependency.set(node.id, 0);
}
// Process nodes in order of decreasing distance
const sortedNodes = Array.from(distances.keys())
.filter((node) => {
const distance = distances.get(node);
return distance !== undefined && distance < Infinity;
})
.sort((a, b) => {
const distanceA = distances.get(a);
const distanceB = distances.get(b);
if (distanceA === undefined || distanceB === undefined) {
return 0;
}
return distanceB - distanceA;
});
for (const node of sortedNodes) {
if (node === source) {
continue;
}
const nodeDependency = dependency.get(node);
const nodePathCount = pathCounts.get(node);
if (nodeDependency === undefined || nodePathCount === undefined) {
continue;
}
// Distribute dependency to predecessors
const nodePredecessors = predecessors.get(node) ?? [];
for (const predecessor of nodePredecessors) {
const predPathCount = pathCounts.get(predecessor);
if (predPathCount === undefined) {
continue;
}
const edgeDependency = (predPathCount / nodePathCount) * (1 + nodeDependency);
// Update predecessor dependency
const currentDependency = dependency.get(predecessor);
if (currentDependency !== undefined) {
dependency.set(predecessor, currentDependency + edgeDependency);
}
// Update edge betweenness
const edgeKey = getEdgeKey(predecessor, node);
const currentBetweenness = edgeBetweenness.get(edgeKey) ?? 0;
edgeBetweenness.set(edgeKey, currentBetweenness + edgeDependency);
}
}
}
// Normalize: divide by 2 for undirected graphs (each edge counted twice)
if (!graph.isDirected) {
for (const [edgeKey, betweenness] of edgeBetweenness) {
edgeBetweenness.set(edgeKey, betweenness / 2);
}
}
return edgeBetweenness;
}
/**
* Find all shortest paths from a source node using BFS
*/
function findAllShortestPaths(graph: Graph, source: NodeId): {
distances: Map<NodeId, number>;
predecessors: Map<NodeId, NodeId[]>;
pathCounts: Map<NodeId, number>;
} {
const distances = new Map<NodeId, number>();
const predecessors = new Map<NodeId, NodeId[]>();
const pathCounts = new Map<NodeId, number>();
const queue: NodeId[] = [];
// Initialize
for (const node of graph.nodes()) {
distances.set(node.id, Infinity);
predecessors.set(node.id, []);
pathCounts.set(node.id, 0);
}
distances.set(source, 0);
pathCounts.set(source, 1);
queue.push(source);
// BFS
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
const currentDistance = distances.get(current);
if (currentDistance === undefined) {
continue;
}
for (const neighbor of graph.neighbors(current)) {
const edge = graph.getEdge(current, neighbor);
const edgeWeight = edge?.weight ?? 1;
const newDistance = currentDistance + edgeWeight;
const neighborDistance = distances.get(neighbor);
if (neighborDistance === undefined) {
continue;
}
if (newDistance < neighborDistance) {
// Found shorter path
distances.set(neighbor, newDistance);
predecessors.set(neighbor, [current]);
const currentPathCount = pathCounts.get(current);
if (currentPathCount !== undefined) {
pathCounts.set(neighbor, currentPathCount);
}
queue.push(neighbor);
} else if (Math.abs(newDistance - neighborDistance) < 1e-10) {
// Found alternative shortest path
const neighborPredecessors = predecessors.get(neighbor);
const neighborPathCount = pathCounts.get(neighbor);
const currentPathCount = pathCounts.get(current);
if (neighborPredecessors && neighborPathCount !== undefined && currentPathCount !== undefined) {
neighborPredecessors.push(current);
pathCounts.set(neighbor, neighborPathCount + currentPathCount);
}
}
}
}
return {distances, predecessors, pathCounts};
}
/**
* Generate a consistent edge key for undirected graphs
*/
function getEdgeKey(source: NodeId, target: NodeId): string {
// For undirected graphs, ensure consistent ordering
const sourceStr = String(source);
const targetStr = String(target);
if (sourceStr <= targetStr) {
return `${sourceStr}|${targetStr}`;
}
return `${targetStr}|${sourceStr}`;
}
/**
* Calculate modularity for a given community structure
*/
function calculateModularity(
graph: Graph,
communityMap: Map<NodeId, number>,
): number {
const totalEdgeWeight = getTotalEdgeWeight(graph);
if (totalEdgeWeight === 0) {
return 0;
}
let modularity = 0;
// Calculate modularity: Q = (1/2m) * Σ[A_ij - (k_i * k_j)/(2m)] * δ(c_i, c_j)
for (const nodeI of graph.nodes()) {
for (const nodeJ of graph.nodes()) {
if (communityMap.get(nodeI.id) === communityMap.get(nodeJ.id)) {
const edge = graph.getEdge(nodeI.id, nodeJ.id);
const edgeWeight = edge?.weight ?? 0;
const degreeI = getNodeDegree(graph, nodeI.id);
const degreeJ = getNodeDegree(graph, nodeJ.id);
modularity += edgeWeight - ((degreeI * degreeJ) / (2 * totalEdgeWeight));
}
}
}
return modularity / (2 * totalEdgeWeight);
}
/**
* Calculate total edge weight in the graph
*/
function getTotalEdgeWeight(graph: Graph): number {
let totalWeight = 0;
for (const edge of graph.edges()) {
totalWeight += edge.weight ?? 1;
}
return totalWeight;
}
/**
* Get the total degree (sum of edge weights) for a node
*/
function getNodeDegree(graph: Graph, nodeId: NodeId): number {
let degree = 0;
for (const neighbor of graph.neighbors(nodeId)) {
const edge = graph.getEdge(nodeId, neighbor);
degree += edge?.weight ?? 1;
}
return degree;
}
/**
* Convert connected components result to ComponentResult format
*/
function getConnectedComponentsResult(graph: Graph): ComponentResult {
const components = connectedComponents(graph);
const componentMap = new Map<NodeId, number>();
components.forEach((component, index) => {
component.forEach((nodeId) => {
componentMap.set(nodeId, index);
});
});
return {components, componentMap};
}
/**
* Create a deep clone of the graph
*/
function cloneGraph(graph: Graph): Graph {
const clone = new Graph({
directed: graph.isDirected,
allowSelfLoops: true,
allowParallelEdges: false,
});
// Add all nodes
for (const node of graph.nodes()) {
clone.addNode(node.id, node.data);
}
// Add all edges
for (const edge of graph.edges()) {
clone.addEdge(edge.source, edge.target, edge.weight, edge.data);
}
return clone;
}