@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
286 lines • 12.1 kB
JavaScript
/**
* Simplified Delta-based PageRank that matches the standard algorithm
* but only processes active nodes for efficiency.
*
* Key optimization: Supports incremental updates when graph structure changes,
* avoiding full recomputation from scratch.
*/
export class SimpleDeltaPageRank {
constructor(graph) {
this.previousScores = null;
if (!graph.isDirected) {
throw new Error("PageRank requires a directed graph");
}
this.graph = graph;
this.nodeCount = graph.nodeCount;
}
compute(options = {}) {
const { dampingFactor = 0.85, tolerance = 1e-6, maxIterations = 100, personalization, weight, } = options;
if (this.nodeCount === 0) {
return new Map();
}
// Initialize scores
const scores = new Map();
const newScores = new Map();
for (const node of this.graph.nodes()) {
scores.set(node.id, 1.0 / this.nodeCount);
}
// Precompute out-degrees and weights
const outWeights = new Map();
const danglingNodes = new Set();
for (const node of this.graph.nodes()) {
let totalWeight = 0;
let outDegree = 0;
for (const neighbor of Array.from(this.graph.neighbors(node.id))) {
outDegree++;
const edge = this.graph.getEdge(node.id, neighbor);
totalWeight += edge?.weight ?? 1;
}
outWeights.set(node.id, totalWeight);
if (outDegree === 0) {
danglingNodes.add(node.id);
}
}
// Normalize personalization vector if provided
let personalVector = null;
if (personalization) {
personalVector = new Map(personalization);
this.normalizeMap(personalVector);
}
// Power iteration with active node tracking
let activeNodes = new Set(scores.keys());
let iteration = 0;
for (iteration = 0; iteration < maxIterations; iteration++) {
// Initialize new scores
for (const nodeId of scores.keys()) {
if (personalVector) {
newScores.set(nodeId, (1 - dampingFactor) * (personalVector.get(nodeId) ?? 0));
}
else {
newScores.set(nodeId, (1 - dampingFactor) / this.nodeCount);
}
}
// Handle dangling nodes
let danglingSum = 0;
for (const danglingNode of danglingNodes) {
danglingSum += scores.get(danglingNode) ?? 0;
}
if (danglingSum > 0) {
const danglingContribution = dampingFactor * danglingSum / this.nodeCount;
for (const nodeId of scores.keys()) {
const current = newScores.get(nodeId) ?? 0;
if (personalVector) {
const personalContrib = danglingContribution * (personalVector.get(nodeId) ?? 0);
newScores.set(nodeId, current + personalContrib);
}
else {
newScores.set(nodeId, current + danglingContribution);
}
}
}
// Propagate rank from active nodes only
for (const nodeId of activeNodes) {
const currentRank = scores.get(nodeId) ?? 0;
const nodeOutWeight = outWeights.get(nodeId) ?? 0;
if (nodeOutWeight > 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 * currentRank * (edgeWeight / nodeOutWeight);
const neighborRank = newScores.get(neighbor) ?? 0;
newScores.set(neighbor, neighborRank + contribution);
}
}
}
// Check convergence and identify active nodes
const nextActive = new Set();
let maxDiff = 0;
for (const nodeId of scores.keys()) {
const oldRank = scores.get(nodeId) ?? 0;
const newRank = newScores.get(nodeId) ?? 0;
const diff = Math.abs(newRank - oldRank);
maxDiff = Math.max(maxDiff, diff);
// Mark as active if change is significant
if (diff > tolerance / 10) {
nextActive.add(nodeId);
// Also mark neighbors as potentially active
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
nextActive.add(neighbor);
}
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
nextActive.add(neighbor);
}
}
}
// Swap score maps
scores.clear();
for (const [k, v] of newScores) {
scores.set(k, v);
}
newScores.clear();
activeNodes = nextActive;
if (maxDiff < tolerance) {
break;
}
}
// Store scores for potential incremental updates
this.previousScores = new Map(scores);
return scores;
}
/**
* Perform incremental update after graph modification.
* This is where delta-based approach provides significant speedup.
*
* @param modifiedNodes Set of nodes that were modified (edges added/removed)
* @param options Computation options
* @returns Updated PageRank scores
*/
update(modifiedNodes, options = {}) {
if (!this.previousScores) {
// No previous computation, do full compute
return this.compute(options);
}
const { dampingFactor = 0.85, tolerance = 1e-6, maxIterations = 100, personalization, weight, } = options;
// Initialize scores from previous computation
const scores = new Map(this.previousScores);
const newScores = new Map();
// Ensure all current nodes are included
for (const node of this.graph.nodes()) {
if (!scores.has(node.id)) {
scores.set(node.id, 1.0 / this.nodeCount);
}
}
// Start with only modified nodes and their neighbors as active
let activeNodes = new Set();
for (const nodeId of modifiedNodes) {
activeNodes.add(nodeId);
// Add incoming neighbors
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
activeNodes.add(neighbor);
}
// Add outgoing neighbors
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
activeNodes.add(neighbor);
}
}
// Recompute out-weights for modified nodes
const outWeights = new Map();
const danglingNodes = new Set();
for (const node of this.graph.nodes()) {
let totalWeight = 0;
let outDegree = 0;
for (const neighbor of Array.from(this.graph.neighbors(node.id))) {
outDegree++;
const edge = this.graph.getEdge(node.id, neighbor);
totalWeight += edge?.weight ?? 1;
}
outWeights.set(node.id, totalWeight);
if (outDegree === 0) {
danglingNodes.add(node.id);
}
}
// Normalize personalization vector if provided
let personalVector = null;
if (personalization) {
personalVector = new Map(personalization);
this.normalizeMap(personalVector);
}
// Run limited iterations focusing on active nodes
let iteration = 0;
for (iteration = 0; iteration < maxIterations && activeNodes.size > 0; iteration++) {
// Initialize new scores
for (const nodeId of scores.keys()) {
if (personalVector) {
newScores.set(nodeId, (1 - dampingFactor) * (personalVector.get(nodeId) ?? 0));
}
else {
newScores.set(nodeId, (1 - dampingFactor) / this.nodeCount);
}
}
// Handle dangling nodes
let danglingSum = 0;
for (const danglingNode of danglingNodes) {
danglingSum += scores.get(danglingNode) ?? 0;
}
if (danglingSum > 0) {
const danglingContribution = dampingFactor * danglingSum / this.nodeCount;
for (const nodeId of scores.keys()) {
const current = newScores.get(nodeId) ?? 0;
if (personalVector) {
const personalContrib = danglingContribution * (personalVector.get(nodeId) ?? 0);
newScores.set(nodeId, current + personalContrib);
}
else {
newScores.set(nodeId, current + danglingContribution);
}
}
}
// Propagate rank from ALL nodes (not just active)
// This ensures correctness
for (const nodeId of scores.keys()) {
const currentRank = scores.get(nodeId) ?? 0;
const nodeOutWeight = outWeights.get(nodeId) ?? 0;
if (nodeOutWeight > 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 * currentRank * (edgeWeight / nodeOutWeight);
const neighborRank = newScores.get(neighbor) ?? 0;
newScores.set(neighbor, neighborRank + contribution);
}
}
}
// Check convergence only for active nodes
const nextActive = new Set();
let maxDiff = 0;
for (const nodeId of activeNodes) {
const oldRank = scores.get(nodeId) ?? 0;
const newRank = newScores.get(nodeId) ?? 0;
const diff = Math.abs(newRank - oldRank);
maxDiff = Math.max(maxDiff, diff);
// Mark as active if change is significant
if (diff > tolerance / 10) {
nextActive.add(nodeId);
// Also mark neighbors as potentially active
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
nextActive.add(neighbor);
}
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
nextActive.add(neighbor);
}
}
}
// Swap score maps
scores.clear();
for (const [k, v] of newScores) {
scores.set(k, v);
}
newScores.clear();
activeNodes = nextActive;
if (maxDiff < tolerance) {
break;
}
}
// Store scores for future incremental updates
this.previousScores = new Map(scores);
return scores;
}
normalizeMap(map) {
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);
}
}
}
}
//# sourceMappingURL=delta-pagerank-simple.js.map