@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
303 lines (261 loc) • 8.41 kB
text/typescript
import type {Graph} from "../../core/graph.js";
import type {NodeId} from "../../types/index.js";
/**
* PageRank algorithm implementation
*
* Measures the importance of nodes in a directed graph based on the
* structure of incoming links. Originally designed for ranking web pages.
*/
/**
* PageRank algorithm options
*/
export interface PageRankOptions {
/**
* Damping factor (probability of following a link) (default: 0.85)
*/
dampingFactor?: number;
/**
* Maximum number of iterations (default: 100)
*/
maxIterations?: number;
/**
* Convergence tolerance (default: 1e-6)
*/
tolerance?: number;
/**
* Initial PageRank values for nodes (default: uniform distribution)
*/
initialRanks?: Map<NodeId, number>;
/**
* Personalization vector for Personalized PageRank (default: null)
*/
personalization?: Map<NodeId, number>;
/**
* Weight attribute for weighted PageRank (default: null = unweighted)
*/
weight?: string;
}
/**
* PageRank algorithm result
*/
export interface PageRankResult {
/**
* PageRank scores for each node
*/
ranks: Record<string, number>;
/**
* Number of iterations until convergence
*/
iterations: number;
/**
* Whether the algorithm converged
*/
converged: boolean;
}
/**
* Calculate PageRank for all nodes in the graph
*/
export function pageRank(
graph: Graph,
options: PageRankOptions = {},
): PageRankResult {
const {
dampingFactor = 0.85,
maxIterations = 100,
tolerance = 1e-6,
initialRanks,
personalization,
weight,
} = options;
if (!graph.isDirected) {
throw new Error("PageRank requires a directed graph");
}
if (dampingFactor < 0 || dampingFactor > 1) {
throw new Error("Damping factor must be between 0 and 1");
}
const nodes = Array.from(graph.nodes()).map((node) => node.id);
const n = nodes.length;
if (n === 0) {
return {ranks: {}, iterations: 0, converged: true};
}
// Initialize PageRank values
let ranks = new Map<NodeId, number>();
if (initialRanks) {
for (const nodeId of nodes) {
ranks.set(nodeId, initialRanks.get(nodeId) ?? 1 / n);
}
} else {
for (const nodeId of nodes) {
ranks.set(nodeId, 1 / n);
}
}
// Normalize initial ranks
normalizeRanks(ranks);
// Calculate out-degrees and handle dangling nodes
const outDegrees = new Map<NodeId, number>();
const outWeights = new Map<NodeId, number>();
const danglingNodes: NodeId[] = [];
for (const nodeId of nodes) {
let outDegree = 0;
let totalOutWeight = 0;
for (const neighbor of Array.from(graph.neighbors(nodeId))) {
outDegree++;
if (weight) {
const edge = graph.getEdge(nodeId, neighbor);
const edgeWeight = edge?.weight ?? 1;
totalOutWeight += edgeWeight;
} else {
totalOutWeight += 1;
}
}
outDegrees.set(nodeId, outDegree);
outWeights.set(nodeId, totalOutWeight);
if (outDegree === 0) {
danglingNodes.push(nodeId);
}
}
// Setup personalization vector
let personalVector: Map<NodeId, number> | null = null;
if (personalization) {
personalVector = new Map(personalization);
normalizeRanks(personalVector);
}
let converged = false;
let iteration = 0;
// Power iteration
for (iteration = 0; iteration < maxIterations; iteration++) {
const newRanks = new Map<NodeId, number>();
// Initialize with teleportation probability
for (const nodeId of nodes) {
if (personalVector) {
newRanks.set(nodeId, (1 - dampingFactor) * (personalVector.get(nodeId) ?? 0));
} else {
newRanks.set(nodeId, (1 - dampingFactor) / n);
}
}
// Handle dangling nodes
let danglingSum = 0;
for (const danglingNode of danglingNodes) {
danglingSum += ranks.get(danglingNode) ?? 0;
}
if (danglingSum > 0) {
const danglingContribution = dampingFactor * danglingSum / n;
for (const nodeId of nodes) {
const currentRank = newRanks.get(nodeId) ?? 0;
if (personalVector) {
const personalContrib = danglingContribution * (personalVector.get(nodeId) ?? 0);
newRanks.set(nodeId, currentRank + personalContrib);
} else {
newRanks.set(nodeId, currentRank + danglingContribution);
}
}
}
// Propagate rank from each node to its neighbors
for (const nodeId of nodes) {
const currentRank = ranks.get(nodeId) ?? 0;
const nodeOutWeight = outWeights.get(nodeId) ?? 0;
if (nodeOutWeight > 0) {
for (const neighbor of Array.from(graph.neighbors(nodeId))) {
let edgeWeight = 1;
if (weight) {
const edge = graph.getEdge(nodeId, neighbor);
edgeWeight = edge?.weight ?? 1;
}
const contribution = dampingFactor * currentRank * (edgeWeight / nodeOutWeight);
const neighborRank = newRanks.get(neighbor) ?? 0;
newRanks.set(neighbor, neighborRank + contribution);
}
}
}
// Check for convergence
let maxDiff = 0;
for (const nodeId of nodes) {
const oldRank = ranks.get(nodeId) ?? 0;
const newRank = newRanks.get(nodeId) ?? 0;
maxDiff = Math.max(maxDiff, Math.abs(newRank - oldRank));
}
ranks = newRanks;
if (maxDiff < tolerance) {
converged = true;
break;
}
}
// Convert to string-keyed record
const result: Record<string, number> = {};
for (const nodeId of nodes) {
result[String(nodeId)] = ranks.get(nodeId) ?? 0;
}
return {
ranks: result,
iterations: iteration + 1,
converged,
};
}
/**
* Calculate Personalized PageRank for a specific set of source nodes
*/
export function personalizedPageRank(
graph: Graph,
personalNodes: NodeId[],
options: Omit<PageRankOptions, "personalization"> = {},
): PageRankResult {
const nodes = Array.from(graph.nodes()).map((node) => node.id);
const personalization = new Map<NodeId, number>();
// Initialize personalization vector
for (const nodeId of nodes) {
personalization.set(nodeId, 0);
}
// Set equal probability for personal nodes
const personalValue = 1 / personalNodes.length;
for (const personalNode of personalNodes) {
if (!graph.hasNode(personalNode)) {
throw new Error(`Personal node ${String(personalNode)} not found in graph`);
}
personalization.set(personalNode, personalValue);
}
return pageRank(graph, {
... options,
personalization,
});
}
/**
* Calculate PageRank centrality (normalized PageRank scores)
*/
export function pageRankCentrality(
graph: Graph,
options: PageRankOptions = {},
): Record<string, number> {
const result = pageRank(graph, options);
return result.ranks;
}
/**
* Get the top-k nodes by PageRank score
*/
export function topPageRankNodes(
graph: Graph,
k: number,
options: PageRankOptions = {},
): {node: NodeId, rank: number}[] {
const result = pageRank(graph, options);
const nodeRanks: {node: NodeId, rank: number}[] = [];
for (const [nodeStr, rank] of Object.entries(result.ranks)) {
nodeRanks.push({node: nodeStr as NodeId, rank});
}
// Sort by rank in descending order
nodeRanks.sort((a, b) => b.rank - a.rank);
return nodeRanks.slice(0, k);
}
/**
* Normalize ranks so they sum to 1
*/
function normalizeRanks(ranks: Map<NodeId, number>): void {
let sum = 0;
for (const rank of ranks.values()) {
sum += rank;
}
if (sum > 0) {
for (const [nodeId, rank] of Array.from(ranks)) {
ranks.set(nodeId, rank / sum);
}
}
}