UNPKG

@graphty/algorithms

Version:

Graph algorithms library for browser environments implemented in TypeScript

436 lines (365 loc) 14.6 kB
import type {Graph} from "../../core/graph.js"; import {PriorityQueue} from "../../data-structures/priority-queue.js"; import type {NodeId} from "../../types/index.js"; /** * Delta-based PageRank implementation for faster convergence * * This algorithm tracks changes (deltas) between iterations and only processes * nodes with significant changes, dramatically reducing computation for graphs * where only some nodes are actively changing their PageRank values. * * Key optimizations: * - Only active nodes (with significant deltas) are processed * - Early termination for converged vertices * - Priority queue processing for high-impact updates * - Adaptive convergence detection per vertex * * Expected speedup: 10-100x for incremental updates on large graphs */ export interface DeltaPageRankOptions { /** * Damping factor (probability of following a link) (default: 0.85) */ dampingFactor?: number; /** * Convergence tolerance (default: 1e-6) */ tolerance?: number; /** * Maximum number of iterations (default: 100) */ maxIterations?: number; /** * Minimum delta to keep vertex active (default: tolerance / 10) */ deltaThreshold?: number; /** * Personalization vector for Personalized PageRank (default: null) */ personalization?: Map<NodeId, number>; /** * Weight attribute for weighted PageRank (default: null = unweighted) */ weight?: string; } export class DeltaPageRank { private graph: Graph; private scores: Map<NodeId, number>; private deltas: Map<NodeId, number>; private activeNodes: Set<NodeId>; private outDegrees: Map<NodeId, number>; private outWeights: Map<NodeId, number>; private danglingNodes: Set<NodeId>; private nodeCount: number; constructor(graph: Graph) { if (!graph.isDirected) { throw new Error("DeltaPageRank requires a directed graph"); } this.graph = graph; this.scores = new Map(); this.deltas = new Map(); this.activeNodes = new Set(); this.outDegrees = new Map(); this.outWeights = new Map(); this.danglingNodes = new Set(); this.nodeCount = graph.nodeCount; this.initialize(); } private initialize(): void { const initialValue = 1.0 / this.nodeCount; // Initialize data structures for (const node of this.graph.nodes()) { const nodeId = node.id; this.scores.set(nodeId, 0); // Start with zero, will add initial value as delta this.deltas.set(nodeId, initialValue); this.activeNodes.add(nodeId); // Precompute out-degrees and weights let outDegree = 0; let totalOutWeight = 0; for (const neighbor of Array.from(this.graph.neighbors(nodeId))) { outDegree++; const edge = this.graph.getEdge(nodeId, neighbor); totalOutWeight += edge?.weight ?? 1; } this.outDegrees.set(nodeId, outDegree); this.outWeights.set(nodeId, totalOutWeight); if (outDegree === 0) { this.danglingNodes.add(nodeId); } } } public compute(options: DeltaPageRankOptions = {}): Map<NodeId, number> { const { dampingFactor = 0.85, tolerance = 1e-6, maxIterations = 100, deltaThreshold = tolerance / 10, personalization, weight, } = options; if (dampingFactor < 0 || dampingFactor > 1) { throw new Error("Damping factor must be between 0 and 1"); } if (this.nodeCount === 0) { return new Map(); } // Normalize personalization vector if provided let personalVector: Map<NodeId, number> | null = null; if (personalization) { personalVector = new Map(personalization); this.normalizeMap(personalVector); } const randomJump = (1 - dampingFactor) / this.nodeCount; let iteration = 0; while (this.activeNodes.size > 0 && iteration < maxIterations) { iteration++; // Create next iteration's deltas const nextDeltas = new Map<NodeId, number>(); // Initialize all nodes with zero delta for (const node of this.graph.nodes()) { nextDeltas.set(node.id, 0); } // Handle dangling nodes contribution let danglingSum = 0; for (const danglingNode of this.danglingNodes) { const currentScore = this.scores.get(danglingNode) ?? 0; const delta = this.deltas.get(danglingNode) ?? 0; danglingSum += currentScore + delta; } if (danglingSum > 0) { const danglingContribution = dampingFactor * danglingSum / this.nodeCount; for (const node of this.graph.nodes()) { const nodeId = node.id; const currentDelta = nextDeltas.get(nodeId) ?? 0; if (personalVector) { const personalContrib = danglingContribution * (personalVector.get(nodeId) ?? 0); nextDeltas.set(nodeId, currentDelta + personalContrib); } else { nextDeltas.set(nodeId, currentDelta + danglingContribution); } } } // Process only active nodes for (const nodeId of this.activeNodes) { const delta = this.deltas.get(nodeId) ?? 0; // Skip if delta is too small if (Math.abs(delta) < deltaThreshold) { continue; } // Apply delta to score const currentScore = this.scores.get(nodeId) ?? 0; this.scores.set(nodeId, currentScore + delta); const outWeight = this.outWeights.get(nodeId) ?? 0; if (outWeight > 0) { // Distribute delta to neighbors for (const neighbor of Array.from(this.graph.neighbors(nodeId))) { let edgeWeight = 1; if (weight) { const edge = this.graph.getEdge(nodeId, neighbor); edgeWeight = edge?.weight ?? 1; } const contribution = dampingFactor * delta * (edgeWeight / outWeight); const currentDelta = nextDeltas.get(neighbor) ?? 0; nextDeltas.set(neighbor, currentDelta + contribution); } } } // Add teleportation probability as delta for (const node of this.graph.nodes()) { const nodeId = node.id; const currentDelta = nextDeltas.get(nodeId) ?? 0; if (personalVector) { nextDeltas.set(nodeId, currentDelta + ((1 - dampingFactor) * (personalVector.get(nodeId) ?? 0))); } else { nextDeltas.set(nodeId, currentDelta + randomJump); } } // Find active nodes for next iteration const nextActive = new Set<NodeId>(); let maxDelta = 0; for (const [nodeId, delta] of nextDeltas) { maxDelta = Math.max(maxDelta, Math.abs(delta)); if (Math.abs(delta) >= deltaThreshold) { nextActive.add(nodeId); } } // Update for next iteration this.deltas = nextDeltas; this.activeNodes = nextActive; // Global convergence check if (maxDelta < tolerance) { break; } } // Normalize final scores this.normalizeMap(this.scores); return new Map(this.scores); } /** * Update PageRank scores after graph modification * This is where delta-based approach really shines */ public update( modifiedNodes: Set<NodeId>, options: DeltaPageRankOptions = {}, ): Map<NodeId, number> { // Mark modified nodes and their neighbors as active this.activeNodes.clear(); for (const nodeId of modifiedNodes) { this.activeNodes.add(nodeId); // Add incoming neighbors for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) { this.activeNodes.add(neighbor); } // Add outgoing neighbors for (const neighbor of Array.from(this.graph.neighbors(nodeId))) { this.activeNodes.add(neighbor); } } // Recompute only for active nodes return this.compute(options); } private normalizeMap(map: Map<NodeId, number>): void { let sum = 0; for (const value of map.values()) { sum += value; } if (sum > 0) { for (const [key, value] of Array.from(map)) { map.set(key, value / sum); } } } } /** * Priority queue based implementation for even better performance * Processes nodes in order of their delta magnitude */ export class PriorityDeltaPageRank { private graph: Graph; private scores: Map<NodeId, number>; private priorityQueue: PriorityQueue<NodeId>; private nodeDeltas: Map<NodeId, number>; private outWeights: Map<NodeId, number>; private danglingNodes: Set<NodeId>; private nodeCount: number; constructor(graph: Graph) { if (!graph.isDirected) { throw new Error("PriorityDeltaPageRank requires a directed graph"); } this.graph = graph; this.scores = new Map(); this.nodeDeltas = new Map(); this.outWeights = new Map(); this.danglingNodes = new Set(); this.nodeCount = graph.nodeCount; // Priority queue ordered by delta magnitude (larger deltas have higher priority) this.priorityQueue = new PriorityQueue<NodeId>((a, b) => b - a); this.initialize(); } private initialize(): void { const initialValue = 1.0 / this.nodeCount; // Initialize all nodes with initial delta for (const node of this.graph.nodes()) { const nodeId = node.id; this.scores.set(nodeId, 0); // Start with zero scores this.nodeDeltas.set(nodeId, initialValue); this.priorityQueue.enqueue(nodeId, initialValue); // Precompute out-weights let totalOutWeight = 0; let outDegree = 0; for (const neighbor of Array.from(this.graph.neighbors(nodeId))) { outDegree++; const edge = this.graph.getEdge(nodeId, neighbor); totalOutWeight += edge?.weight ?? 1; } this.outWeights.set(nodeId, totalOutWeight); if (outDegree === 0) { this.danglingNodes.add(nodeId); } } } public computeWithPriority(options: DeltaPageRankOptions = {}): Map<NodeId, number> { const { dampingFactor = 0.85, tolerance = 1e-6, maxIterations = 100, deltaThreshold = tolerance / 10, weight, } = options; let iteration = 0; let processedCount = 0; while (!this.priorityQueue.isEmpty() && iteration < maxIterations) { const nodeId = this.priorityQueue.dequeue(); if (nodeId === undefined) { break; } const delta = this.nodeDeltas.get(nodeId) ?? 0; // Skip if delta is too small if (Math.abs(delta) < deltaThreshold) { continue; } // Apply delta to node's score const currentScore = this.scores.get(nodeId) ?? 0; this.scores.set(nodeId, currentScore + delta); this.nodeDeltas.set(nodeId, 0); // Distribute delta to neighbors const outWeight = this.outWeights.get(nodeId) ?? 0; if (outWeight > 0) { for (const neighbor of Array.from(this.graph.neighbors(nodeId))) { let edgeWeight = 1; if (weight) { const edge = this.graph.getEdge(nodeId, neighbor); edgeWeight = edge?.weight ?? 1; } const contribution = dampingFactor * delta * (edgeWeight / outWeight); const currentNeighborDelta = this.nodeDeltas.get(neighbor) ?? 0; const newDelta = currentNeighborDelta + contribution; this.nodeDeltas.set(neighbor, newDelta); // Re-enqueue neighbor with updated priority if (Math.abs(newDelta) >= deltaThreshold) { this.priorityQueue.enqueue(neighbor, Math.abs(newDelta)); } } } processedCount++; // Periodic convergence check if (processedCount % 1000 === 0) { let maxDelta = 0; for (const d of this.nodeDeltas.values()) { maxDelta = Math.max(maxDelta, Math.abs(d)); } if (maxDelta < tolerance) { break; } } iteration++; } // Apply any remaining deltas for (const [nodeId, delta] of this.nodeDeltas) { if (Math.abs(delta) >= deltaThreshold) { const currentScore = this.scores.get(nodeId) ?? 0; this.scores.set(nodeId, currentScore + delta); } } // Add teleportation probability const teleport = (1 - dampingFactor) / this.nodeCount; for (const [nodeId, score] of this.scores) { this.scores.set(nodeId, score + teleport); } // Normalize final scores this.normalizeMap(this.scores); return new Map(this.scores); } private normalizeMap(map: Map<NodeId, number>): void { let sum = 0; for (const value of map.values()) { sum += value; } if (sum > 0) { for (const [key, value] of Array.from(map)) { map.set(key, value / sum); } } } }