@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
436 lines (365 loc) • 14.6 kB
text/typescript
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);
}
}
}
}