@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
343 lines • 10.5 kB
JavaScript
/**
* Perform Markov Clustering on a graph
*/
export function markovClustering(graph, options = {}) {
const { expansion = 2, inflation = 2, maxIterations = 100, tolerance = 1e-6, pruningThreshold = 1e-5, selfLoops = true, } = options;
const nodes = Array.from(graph.nodes());
const nodeIds = nodes.map((node) => node.id);
const n = nodeIds.length;
if (n === 0) {
return {
communities: [],
attractors: new Set(),
iterations: 0,
converged: true,
};
}
// Build initial transition matrix
let matrix = buildTransitionMatrix(graph, nodeIds, selfLoops);
let converged = false;
let iteration = 0;
for (iteration = 0; iteration < maxIterations; iteration++) {
const oldMatrix = matrix.map((row) => [...row]);
// Expansion step (matrix multiplication)
matrix = matrixPower(matrix, expansion);
// Inflation step (element-wise powering and column normalization)
matrix = inflate(matrix, inflation);
// Pruning step (remove small values)
matrix = prune(matrix, pruningThreshold);
// Check for convergence
if (hasConverged(oldMatrix, matrix, tolerance)) {
converged = true;
break;
}
}
// Extract clusters from final matrix
const { communities, attractors } = extractClusters(matrix, nodeIds);
return {
communities,
attractors,
iterations: iteration + 1,
converged,
};
}
/**
* Build initial transition matrix from graph
*/
function buildTransitionMatrix(graph, nodeIds, selfLoops) {
const n = nodeIds.length;
const matrix = Array.from({ length: n }, () => Array(n).fill(0));
const nodeToIndex = new Map();
nodeIds.forEach((id, index) => nodeToIndex.set(id, index));
// Fill adjacency values
for (let i = 0; i < n; i++) {
const nodeId = nodeIds[i];
if (!nodeId) {
continue;
}
const neighbors = graph.neighbors(nodeId);
for (const neighbor of neighbors) {
const j = nodeToIndex.get(neighbor);
if (j !== undefined) {
const edge = graph.getEdge(nodeId, neighbor);
const weight = edge?.weight ?? 1;
const row = matrix[i];
if (!row) {
continue;
}
row[j] = weight;
}
}
// Add self-loops
if (selfLoops) {
const row = matrix[i];
if (!row) {
continue;
}
row[i] = 1;
}
}
// Column-normalize the matrix
for (let j = 0; j < n; j++) {
let colSum = 0;
for (let i = 0; i < n; i++) {
const val = matrix[i]?.[j];
if (val !== undefined) {
colSum += val;
}
}
if (colSum > 0) {
for (let i = 0; i < n; i++) {
const row = matrix[i];
if (!row) {
continue;
}
const val = row[j];
if (val !== undefined) {
row[j] = val / colSum;
}
}
}
}
return matrix;
}
/**
* Raise matrix to a power (for expansion step)
*/
function matrixPower(matrix, power) {
if (power === 1) {
return matrix;
}
if (power === 2) {
return matrixMultiply(matrix, matrix);
}
let result = matrix;
for (let i = 1; i < power; i++) {
result = matrixMultiply(result, matrix);
}
return result;
}
/**
* Multiply two matrices
*/
function matrixMultiply(a, b) {
const n = a.length;
const m = b[0]?.length ?? 0;
const p = b.length;
const result = Array.from({ length: n }, () => Array(m).fill(0));
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
for (let k = 0; k < p; k++) {
const aVal = a[i]?.[k] ?? 0;
const bVal = b[k]?.[j] ?? 0;
const resultRow = result[i];
if (!resultRow) {
continue;
}
const prevVal = resultRow[j];
if (prevVal !== undefined) {
resultRow[j] = prevVal + (aVal * bVal);
}
}
}
}
return result;
}
/**
* Inflation step: element-wise powering and column normalization
*/
function inflate(matrix, inflation) {
const n = matrix.length;
const result = Array.from({ length: n }, () => Array(n).fill(0));
// Element-wise powering
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const val = matrix[i]?.[j];
if (val !== undefined) {
const resultRow = result[i];
if (resultRow) {
resultRow[j] = Math.pow(val, inflation);
}
}
}
}
// Column normalization
for (let j = 0; j < n; j++) {
let colSum = 0;
for (let i = 0; i < n; i++) {
const val = result[i]?.[j];
if (val !== undefined) {
colSum += val;
}
}
if (colSum > 0) {
for (let i = 0; i < n; i++) {
const row = result[i];
if (!row) {
continue;
}
const val = row[j];
if (val !== undefined) {
row[j] = val / colSum;
}
}
}
}
return result;
}
/**
* Pruning step: remove small values
*/
function prune(matrix, threshold) {
const n = matrix.length;
const result = Array.from({ length: n }, () => Array(n).fill(0));
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const matrixRow = matrix[i];
if (!matrixRow) {
continue;
}
const matrixVal = matrixRow[j];
if (matrixVal !== undefined && matrixVal >= threshold) {
const resultRow = result[i];
if (resultRow) {
resultRow[j] = matrixVal;
}
}
}
}
// Re-normalize columns after pruning
for (let j = 0; j < n; j++) {
let colSum = 0;
for (let i = 0; i < n; i++) {
const resultRow = result[i];
if (!resultRow) {
continue;
}
const val = resultRow[j];
if (val !== undefined) {
colSum += val;
}
}
if (colSum > 0) {
for (let i = 0; i < n; i++) {
const resultRow = result[i];
if (!resultRow) {
continue;
}
const val = resultRow[j];
if (val !== undefined) {
resultRow[j] = val / colSum;
}
}
}
}
return result;
}
/**
* Check if the algorithm has converged
*/
function hasConverged(oldMatrix, newMatrix, tolerance) {
const n = oldMatrix.length;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const oldRow = oldMatrix[i];
const newRow = newMatrix[i];
if (!oldRow || !newRow) {
continue;
}
const oldVal = oldRow[j];
const newVal = newRow[j];
if (oldVal !== undefined && newVal !== undefined && Math.abs(oldVal - newVal) > tolerance) {
return false;
}
}
}
return true;
}
/**
* Extract clusters from the final matrix
*/
function extractClusters(matrix, nodeIds) {
const n = matrix.length;
const attractors = new Set();
const communities = [];
const nodeToCluster = new Map();
// Find attractors (columns with non-zero diagonal elements)
for (let i = 0; i < n; i++) {
const matrixRow = matrix[i];
if (!matrixRow) {
continue;
}
const diagonalVal = matrixRow[i];
const nodeId = nodeIds[i];
if (diagonalVal !== undefined && diagonalVal > 0 && nodeId !== undefined) {
attractors.add(nodeId);
}
}
// Assign nodes to clusters based on columns
let clusterIndex = 0;
for (let j = 0; j < n; j++) {
// Find nodes that belong to this cluster (column)
const clusterNodes = [];
for (let i = 0; i < n; i++) {
const matrixRow = matrix[i];
if (!matrixRow) {
continue;
}
const val = matrixRow[j];
if (val !== undefined && val > 0 && !nodeToCluster.has(i)) {
clusterNodes.push(i);
nodeToCluster.set(i, clusterIndex);
}
}
if (clusterNodes.length > 0) {
communities.push(clusterNodes.map((idx) => {
const nodeId = nodeIds[idx];
return nodeId;
}).filter((node) => node !== undefined));
clusterIndex++;
}
}
// Handle isolated nodes
for (let i = 0; i < n; i++) {
if (!nodeToCluster.has(i)) {
const nodeId = nodeIds[i];
if (nodeId !== undefined) {
communities.push([nodeId]);
}
nodeToCluster.set(i, clusterIndex++);
}
}
return { communities, attractors };
}
/**
* Calculate modularity of MCL clustering result
*/
export function calculateMCLModularity(graph, communities) {
const m = graph.totalEdgeCount;
if (m === 0) {
return 0;
}
let modularity = 0;
const communityMap = new Map();
// Build community map
communities.forEach((community, index) => {
community.forEach((nodeId) => {
communityMap.set(nodeId, index);
});
});
// Calculate modularity
for (const edge of graph.edges()) {
const sourceCommunity = communityMap.get(edge.source);
const targetCommunity = communityMap.get(edge.target);
if (sourceCommunity !== undefined && targetCommunity !== undefined && sourceCommunity === targetCommunity) {
modularity += 1;
}
const sourceDegree = graph.degree(edge.source);
const targetDegree = graph.degree(edge.target);
modularity -= (sourceDegree * targetDegree) / (2 * m);
}
return modularity / (2 * m);
}
//# sourceMappingURL=mcl.js.map