ngraph.leiden
Version:
Leiden/Louvain community detection for ngraph.graph (JS)
346 lines (325 loc) • 15.5 kB
JavaScript
// Mutable community assignment with per-community aggregates.
// WHY: Optimiser moves nodes between communities and needs fast ΔQ (quality) updates.
// This structure keeps per-community totals and per-move scratch accumulators so
// we can compute modularity/CPM gains in O(neighborhood) time without rescanning
// the whole graph after each move.
// HOW: Maintains arrays for community sizes, node counts, internal edge weights and
// strengths (undirected: totalStrength; directed: totalOutStrength/totalInStrength).
// For each node under consideration, it accumulates edge weights to neighboring
// communities into scratch buffers and evaluates ΔQ for candidate communities.
// Supports undirected and directed modularity and CPM.
export function makePartition(graph) {
const n = graph.n;
// initial mapping: each node in its own community
const nodeCommunity = new Int32Array(n);
for (let i = 0; i < n; i++) nodeCommunity[i] = i;
let communityCount = n; // communities are 0..communityCount-1 initially
// per-community aggregates
let communityTotalSize = new Float64Array(communityCount);
let communityNodeCount = new Int32Array(communityCount);
let communityInternalEdgeWeight = new Float64Array(communityCount); // edges fully inside community
let communityTotalStrength = new Float64Array(communityCount); // undirected: sum of strengths
let communityTotalOutStrength = new Float64Array(communityCount); // directed: Σ k_out
let communityTotalInStrength = new Float64Array(communityCount); // directed: Σ k_in
// scratch buffers for neighbor community accumulation
let candidateCommunities = new Int32Array(n);
let candidateCommunityCount = 0;
let neighborEdgeWeightToCommunity = new Float64Array(n); // undirected/outgoing weight to community
let outEdgeWeightToCommunity = new Float64Array(n); // directed: v->C sum
let inEdgeWeightFromCommunity = new Float64Array(n); // directed: C->v sum
let isCandidateCommunity = new Uint8Array(n);
function ensureCommCapacity(newCount) {
if (newCount <= communityTotalSize.length) return;
const growTo = Math.max(newCount, Math.ceil(communityTotalSize.length * 1.5));
communityTotalSize = growFloat(communityTotalSize, growTo);
communityNodeCount = growInt(communityNodeCount, growTo);
communityInternalEdgeWeight = growFloat(communityInternalEdgeWeight, growTo);
communityTotalStrength = growFloat(communityTotalStrength, growTo);
communityTotalOutStrength = growFloat(communityTotalOutStrength, growTo);
communityTotalInStrength = growFloat(communityTotalInStrength, growTo);
}
function initializeAggregates() {
communityTotalSize.fill(0);
communityNodeCount.fill(0);
communityInternalEdgeWeight.fill(0);
communityTotalStrength.fill(0);
communityTotalOutStrength.fill(0);
communityTotalInStrength.fill(0);
for (let i = 0; i < n; i++) {
const c = nodeCommunity[i];
communityTotalSize[c] += graph.size[i];
communityNodeCount[c] += 1;
if (graph.directed) {
communityTotalOutStrength[c] += graph.strengthOut[i];
communityTotalInStrength[c] += graph.strengthIn[i];
} else {
communityTotalStrength[c] += graph.strengthOut[i];
}
// self-loop contributes to internal
if (graph.selfLoop[i] !== 0) communityInternalEdgeWeight[c] += graph.selfLoop[i];
}
// accumulate internal weights from edges
if (graph.directed) {
for (let i = 0; i < n; i++) {
const ci = nodeCommunity[i];
const neighbors = graph.outEdges[i];
for (let k = 0; k < neighbors.length; k++) {
const { to: j, w } = neighbors[k];
if (ci === nodeCommunity[j]) communityInternalEdgeWeight[ci] += w;
}
}
} else {
// avoid double counting by only i -> j where i < j
for (let i = 0; i < n; i++) {
const ci = nodeCommunity[i];
const neighbors = graph.outEdges[i];
for (let k = 0; k < neighbors.length; k++) {
const { to: j, w } = neighbors[k];
if (j <= i) continue;
if (ci === nodeCommunity[j]) communityInternalEdgeWeight[ci] += w;
}
}
}
}
function resetScratch() {
for (let i = 0; i < candidateCommunityCount; i++) {
const c = candidateCommunities[i];
isCandidateCommunity[c] = 0;
neighborEdgeWeightToCommunity[c] = 0;
outEdgeWeightToCommunity[c] = 0;
inEdgeWeightFromCommunity[c] = 0;
}
candidateCommunityCount = 0;
}
function touch(c) {
if (isCandidateCommunity[c]) return;
isCandidateCommunity[c] = 1;
candidateCommunities[candidateCommunityCount++] = c;
}
function accumulateNeighborCommunityEdgeWeights(v) {
resetScratch();
const ci = nodeCommunity[v];
// include staying in same community
touch(ci);
// accumulate over neighbors
if (graph.directed) {
const outL = graph.outEdges[v];
for (let k = 0; k < outL.length; k++) {
const j = outL[k].to; const w = outL[k].w;
const cj = nodeCommunity[j];
touch(cj);
outEdgeWeightToCommunity[cj] += w;
}
const inL = graph.inEdges[v];
for (let k = 0; k < inL.length; k++) {
const i2 = inL[k].from; const w = inL[k].w;
const ci2 = nodeCommunity[i2];
touch(ci2);
inEdgeWeightFromCommunity[ci2] += w;
}
} else {
const list = graph.outEdges[v];
for (let k = 0; k < list.length; k++) {
const j = list[k].to; const w = list[k].w;
const cj = nodeCommunity[j];
touch(cj);
neighborEdgeWeightToCommunity[cj] += w;
}
}
return candidateCommunityCount;
}
// Quality support: undirected modularity
const twoMUndirected = graph.totalWeight; // sum of strengths (2m for undirected)
function deltaModularityUndirected(v, newC) {
const oldC = nodeCommunity[v];
if (newC === oldC) return 0;
const strengthV = graph.strengthOut[v];
const weightToNew = (newC < neighborEdgeWeightToCommunity.length ? (neighborEdgeWeightToCommunity[newC] || 0) : 0); // weight from v to newC
const weightToOld = neighborEdgeWeightToCommunity[oldC] || 0; // weight from v to oldC
const totalStrengthNew = newC < communityTotalStrength.length ? communityTotalStrength[newC] : 0;
const totalStrengthOld = communityTotalStrength[oldC];
// Modularity gain formula (Blondel 2008), adjusted for removal/addition
const gain_remove = - (weightToOld / twoMUndirected - (strengthV * totalStrengthOld) / (twoMUndirected * twoMUndirected));
const gain_add = (weightToNew / twoMUndirected - (strengthV * totalStrengthNew) / (twoMUndirected * twoMUndirected));
return gain_remove + gain_add;
}
// Directed modularity (Leicht-Newman). Here m = totalWeight (sum of all edges' weights)
function deltaModularityDirected(v, newC) {
const oldC = nodeCommunity[v];
if (newC === oldC) return 0;
const totalEdgeWeight = graph.totalWeight;
const strengthOutV = graph.strengthOut[v];
const strengthInV = graph.strengthIn[v];
const inFromNew = (newC < inEdgeWeightFromCommunity.length ? (inEdgeWeightFromCommunity[newC] || 0) : 0);
const outToNew = (newC < outEdgeWeightToCommunity.length ? (outEdgeWeightToCommunity[newC] || 0) : 0);
const inFromOld = inEdgeWeightFromCommunity[oldC] || 0;
const outToOld = outEdgeWeightToCommunity[oldC] || 0;
const totalInStrengthNew = (newC < communityTotalInStrength.length ? communityTotalInStrength[newC] : 0);
const totalOutStrengthNew = (newC < communityTotalOutStrength.length ? communityTotalOutStrength[newC] : 0);
const totalInStrengthOld = communityTotalInStrength[oldC];
const totalOutStrengthOld = communityTotalOutStrength[oldC];
const deltaInternal = (inFromNew + outToNew - inFromOld - outToOld) / totalEdgeWeight;
const deltaExpected = (strengthOutV * (totalInStrengthNew - totalInStrengthOld) + strengthInV * (totalOutStrengthNew - totalOutStrengthOld)) / (totalEdgeWeight * totalEdgeWeight);
return deltaInternal - deltaExpected;
}
// CPM (Constant Potts Model) diff for undirected case (unit-size correct; sizes generalized via s_v)
function deltaCPM(v, newC, gamma = 1.0) {
const oldC = nodeCommunity[v];
if (newC === oldC) return 0;
const weightToOld = neighborEdgeWeightToCommunity[oldC] || 0;
const weightToNew = (newC < neighborEdgeWeightToCommunity.length ? (neighborEdgeWeightToCommunity[newC] || 0) : 0);
const nodeSize = graph.size[v] || 1;
const sizeOld = communityTotalSize[oldC] || 0;
const sizeNew = (newC < communityTotalSize.length ? communityTotalSize[newC] : 0);
// ΔQ_internal = (w_new - w_old)
// ΔQ_penalty = -gamma * s_v * (S_new - S_old + s_v)
return (weightToNew - weightToOld) - gamma * nodeSize * (sizeNew - sizeOld + nodeSize);
}
function moveNodeToCommunity(v, newC) {
const oldC = nodeCommunity[v];
if (oldC === newC) return false;
// creating a brand new community if newC equals current q
if (newC >= communityCount) {
ensureCommCapacity(newC + 1);
// zero-initialize new slots (already zero by default arrays)
communityCount = newC + 1;
}
const strengthOutV = graph.strengthOut[v];
const strengthInV = graph.strengthIn[v];
const selfLoopWeight = graph.selfLoop[v];
const nodeSize = graph.size[v];
// update community totals
communityNodeCount[oldC] -= 1; communityNodeCount[newC] += 1;
communityTotalSize[oldC] -= nodeSize; communityTotalSize[newC] += nodeSize;
if (graph.directed) {
communityTotalOutStrength[oldC] -= strengthOutV; communityTotalOutStrength[newC] += strengthOutV;
communityTotalInStrength[oldC] -= strengthInV; communityTotalInStrength[newC] += strengthInV;
} else {
communityTotalStrength[oldC] -= strengthOutV; communityTotalStrength[newC] += strengthOutV;
}
// internal weights: subtract connections to old, add to new
if (graph.directed) {
const outToOld = outEdgeWeightToCommunity[oldC] || 0;
const inFromOld = inEdgeWeightFromCommunity[oldC] || 0;
const outToNew = (newC < outEdgeWeightToCommunity.length ? (outEdgeWeightToCommunity[newC] || 0) : 0);
const inFromNew = (newC < inEdgeWeightFromCommunity.length ? (inEdgeWeightFromCommunity[newC] || 0) : 0);
communityInternalEdgeWeight[oldC] -= (outToOld + inFromOld + selfLoopWeight);
communityInternalEdgeWeight[newC] += (outToNew + inFromNew + selfLoopWeight);
} else {
const weightToOld = neighborEdgeWeightToCommunity[oldC] || 0;
const weightToNew = neighborEdgeWeightToCommunity[newC] || 0;
communityInternalEdgeWeight[oldC] -= 2 * weightToOld + selfLoopWeight;
communityInternalEdgeWeight[newC] += 2 * weightToNew + selfLoopWeight;
}
nodeCommunity[v] = newC;
return true;
}
function compactCommunityIds(opts = {}) {
// compact to 0..q'-1
const ids = [];
for (let c = 0; c < communityCount; c++) if (communityNodeCount[c] > 0) ids.push(c);
if (opts.keepOldOrder) {
// Preserve existing order: stable by old id
ids.sort((a, b) => a - b);
} else if (opts.preserveMap instanceof Map) {
// Sort by provided mapping first (ascending), then by size as tiebreaker
ids.sort((a, b) => {
const pa = opts.preserveMap.get(a);
const pb = opts.preserveMap.get(b);
if (pa != null && pb != null && pa !== pb) return pa - pb;
if (pa != null && pb == null) return -1;
if (pb != null && pa == null) return 1;
return (communityTotalSize[b] - communityTotalSize[a]) || (communityNodeCount[b] - communityNodeCount[a]) || (a - b);
});
} else {
// default: decreasing by size then count then old id
ids.sort((a, b) => (communityTotalSize[b] - communityTotalSize[a]) || (communityNodeCount[b] - communityNodeCount[a]) || (a - b));
}
const newId = new Int32Array(communityCount).fill(-1);
ids.forEach((c, i) => { newId[c] = i; });
for (let i = 0; i < nodeCommunity.length; i++) nodeCommunity[i] = newId[nodeCommunity[i]];
// rebuild aggregates in new order
const remappedCount = ids.length;
const newTotalSize = new Float64Array(remappedCount);
const newNodeCount = new Int32Array(remappedCount);
const newInternalEdgeWeight = new Float64Array(remappedCount);
const newTotalStrength = new Float64Array(remappedCount);
const newTotalOutStrength = new Float64Array(remappedCount);
const newTotalInStrength = new Float64Array(remappedCount);
for (let i = 0; i < n; i++) {
const c = nodeCommunity[i];
newTotalSize[c] += graph.size[i];
newNodeCount[c] += 1;
if (graph.directed) {
newTotalOutStrength[c] += graph.strengthOut[i];
newTotalInStrength[c] += graph.strengthIn[i];
} else {
newTotalStrength[c] += graph.strengthOut[i];
}
}
// recompute wIn by scanning edges once
if (graph.directed) {
for (let i = 0; i < n; i++) {
const ci = nodeCommunity[i];
const list = graph.outEdges[i];
for (let k = 0; k < list.length; k++) {
const { to: j, w } = list[k];
if (ci === nodeCommunity[j]) newInternalEdgeWeight[ci] += w;
}
}
} else {
for (let i = 0; i < n; i++) {
const ci = nodeCommunity[i];
const list = graph.outEdges[i];
for (let k = 0; k < list.length; k++) {
const { to: j, w } = list[k];
if (j <= i) continue;
if (ci === nodeCommunity[j]) newInternalEdgeWeight[ci] += w;
}
}
}
communityCount = remappedCount;
communityTotalSize = newTotalSize;
communityNodeCount = newNodeCount;
communityInternalEdgeWeight = newInternalEdgeWeight;
communityTotalStrength = newTotalStrength;
communityTotalOutStrength = newTotalOutStrength;
communityTotalInStrength = newTotalInStrength;
}
function getCommunityMembers() {
const comms = new Array(communityCount); for (let i = 0; i < communityCount; i++) comms[i] = [];
for (let i = 0; i < n; i++) comms[nodeCommunity[i]].push(i);
return comms;
}
function getCommunityTotalSize(c) { return c < communityTotalSize.length ? communityTotalSize[c] : 0; }
function getCommunityNodeCount(c) { return c < communityNodeCount.length ? communityNodeCount[c] : 0; }
// Expose minimal API
return {
n,
get communityCount() { return communityCount; },
nodeCommunity,
communityTotalSize,
communityNodeCount,
communityInternalEdgeWeight,
communityTotalStrength,
communityTotalOutStrength,
communityTotalInStrength,
initializeAggregates,
accumulateNeighborCommunityEdgeWeights,
getCandidateCommunityCount: () => candidateCommunityCount,
getCandidateCommunityAt: (i) => candidateCommunities[i],
getNeighborEdgeWeightToCommunity: (c) => neighborEdgeWeightToCommunity[c] || 0,
getOutEdgeWeightToCommunity: (c) => outEdgeWeightToCommunity[c] || 0,
getInEdgeWeightFromCommunity: (c) => inEdgeWeightFromCommunity[c] || 0,
deltaModularityUndirected,
deltaModularityDirected,
deltaCPM,
moveNodeToCommunity,
compactCommunityIds,
getCommunityMembers,
getCommunityTotalSize,
getCommunityNodeCount,
};
}
function growFloat(a, to) { const b = new Float64Array(to); b.set(a); return b; }
function growInt(a, to) { const b = new Int32Array(to); b.set(a); return b; }