@graphty/algorithms 1.8.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/algorithms.js +518 -525
- package/dist/algorithms.js.map +1 -1
- package/dist/algorithms.standalone.js +519 -526
- package/dist/algorithms.standalone.js.map +1 -1
- package/dist/src/algorithms/centrality/eigenvector.d.ts +13 -2
- package/dist/src/algorithms/centrality/eigenvector.d.ts.map +1 -1
- package/dist/src/algorithms/centrality/eigenvector.js +96 -73
- package/dist/src/algorithms/centrality/eigenvector.js.map +1 -1
- package/dist/src/algorithms/community/girvan-newman.js +35 -15
- package/dist/src/algorithms/community/girvan-newman.js.map +1 -1
- package/dist/src/algorithms/community/leiden.d.ts.map +1 -1
- package/dist/src/algorithms/community/leiden.js +214 -222
- package/dist/src/algorithms/community/leiden.js.map +1 -1
- package/dist/src/algorithms/community/louvain-optimized.d.ts +28 -72
- package/dist/src/algorithms/community/louvain-optimized.d.ts.map +1 -1
- package/dist/src/algorithms/community/louvain-optimized.js +249 -262
- package/dist/src/algorithms/community/louvain-optimized.js.map +1 -1
- package/dist/src/algorithms/community/modularity-utils.d.ts +15 -7
- package/dist/src/algorithms/community/modularity-utils.d.ts.map +1 -1
- package/dist/src/algorithms/community/modularity-utils.js +38 -38
- package/dist/src/algorithms/community/modularity-utils.js.map +1 -1
- package/dist/src/algorithms/shortest-path/bellman-ford.d.ts.map +1 -1
- package/dist/src/algorithms/shortest-path/bellman-ford.js +35 -8
- package/dist/src/algorithms/shortest-path/bellman-ford.js.map +1 -1
- package/dist/src/errors.d.ts +21 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/package.json +11 -8
- package/src/algorithms/centrality/eigenvector.ts +103 -84
- package/src/algorithms/community/girvan-newman.ts +38 -17
- package/src/algorithms/community/leiden.ts +270 -275
- package/src/algorithms/community/louvain-optimized.ts +303 -313
- package/src/algorithms/community/modularity-utils.ts +42 -45
- package/src/algorithms/shortest-path/bellman-ford.ts +46 -8
- package/src/errors.ts +26 -0
- package/src/index.ts +3 -0
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -25,6 +25,9 @@ export interface LeidenResult {
|
|
|
25
25
|
iterations: number;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** How many local-moving sweeps one level may take before it is declared settled. */
|
|
29
|
+
const LOCAL_MOVE_PASSES = 50;
|
|
30
|
+
|
|
28
31
|
/**
|
|
29
32
|
* Internal implementation of Leiden algorithm for community detection
|
|
30
33
|
* Improves upon Louvain by ensuring well-connected communities
|
|
@@ -44,145 +47,101 @@ function leidenImpl(inputGraph: Map<string, Map<string, number>>, options: Leide
|
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
// Use a mutable variable for the current graph state
|
|
48
|
-
let currentGraph = inputGraph;
|
|
49
|
-
|
|
50
|
-
// Initialize random number generator
|
|
51
50
|
const random = SeededRandom.createGenerator(randomSeed);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
degree += weight;
|
|
61
|
-
totalWeight += weight;
|
|
62
|
-
}
|
|
63
|
-
degrees.set(node, degree);
|
|
51
|
+
const base = weighLevel(inputGraph);
|
|
52
|
+
|
|
53
|
+
// Which node of the CURRENT level holds each original node. Every partition is scored against
|
|
54
|
+
// the original graph through this map, so an aggregation can never leave the answer keyed by
|
|
55
|
+
// the super-node names a caller has never heard of -- which is what it used to do.
|
|
56
|
+
const placement = new Map<string, string>();
|
|
57
|
+
for (const node of inputGraph.keys()) {
|
|
58
|
+
placement.set(node, node);
|
|
64
59
|
}
|
|
65
|
-
totalWeight /= 2; // Each edge counted twice
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
nodes.forEach((node, i) => communities.set(node, i));
|
|
71
|
-
|
|
72
|
-
let modularity = calculateModularity(currentGraph, communities, degrees, totalWeight, resolution);
|
|
73
|
-
let bestModularity = modularity;
|
|
74
|
-
let bestCommunities = new Map(communities);
|
|
61
|
+
let bestCommunities = singletonCommunities(inputGraph);
|
|
62
|
+
let bestModularity = calculateModularity(inputGraph, bestCommunities, base.degrees, base.totalWeight, resolution);
|
|
63
|
+
let levelGraph = inputGraph;
|
|
75
64
|
let iterations = 0;
|
|
76
65
|
|
|
77
|
-
//
|
|
66
|
+
// Whether this level is the ORIGINAL graph, started from the best partition so far rather than
|
|
67
|
+
// from singletons. Local moving on aggregated levels can only move whole super-nodes, so a node
|
|
68
|
+
// merged into the wrong community early could never leave it, and the answer could be beaten by
|
|
69
|
+
// moving one node. The paper (Traag et al. 2019) iterates until a pass over the original nodes
|
|
70
|
+
// changes nothing; returning to them once the levels settle is that pass.
|
|
71
|
+
let polishing = false;
|
|
72
|
+
|
|
78
73
|
while (iterations < maxIterations) {
|
|
79
74
|
iterations++;
|
|
80
|
-
let improved = false;
|
|
81
75
|
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
76
|
+
const weights = weighLevel(levelGraph);
|
|
77
|
+
const communities = polishing ? new Map(bestCommunities) : singletonCommunities(levelGraph);
|
|
78
|
+
const moved = moveNodesLocally(levelGraph, weights, communities, random, resolution, LOCAL_MOVE_PASSES);
|
|
85
79
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
// Leiden's guarantee over Louvain: a community whose members are not connected to each
|
|
81
|
+
// other inside this level's graph is split into the pieces that are.
|
|
82
|
+
for (const [node, piece] of refinePartition(levelGraph, communities)) {
|
|
83
|
+
communities.set(node, piece);
|
|
84
|
+
}
|
|
91
85
|
|
|
92
|
-
|
|
86
|
+
const candidate = new Map<string, number>();
|
|
87
|
+
for (const [original, levelNode] of placement) {
|
|
88
|
+
const community = communities.get(levelNode);
|
|
89
|
+
if (community !== undefined) {
|
|
90
|
+
candidate.set(original, community);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
const modularity = calculateModularity(inputGraph, candidate, base.degrees, base.totalWeight, resolution);
|
|
95
|
+
const improved = modularity > bestModularity + threshold;
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
97
|
+
if (modularity > bestModularity) {
|
|
98
|
+
bestModularity = modularity;
|
|
99
|
+
bestCommunities = candidate;
|
|
100
|
+
}
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
community,
|
|
106
|
-
currentGraph,
|
|
107
|
-
communities,
|
|
108
|
-
degrees,
|
|
109
|
-
totalWeight,
|
|
110
|
-
resolution,
|
|
111
|
-
);
|
|
102
|
+
const distinct = new Set(communities.values()).size;
|
|
103
|
+
const settled = polishing ? !improved : !improved || !moved || distinct === levelGraph.size;
|
|
112
104
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
105
|
+
if (settled) {
|
|
106
|
+
if (polishing) {
|
|
107
|
+
break;
|
|
117
108
|
}
|
|
118
109
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
110
|
+
// The levels have settled: go back to the original nodes, starting from the answer.
|
|
111
|
+
polishing = true;
|
|
112
|
+
levelGraph = inputGraph;
|
|
113
|
+
for (const node of inputGraph.keys()) {
|
|
114
|
+
placement.set(node, node);
|
|
124
115
|
}
|
|
125
|
-
}
|
|
126
116
|
|
|
127
|
-
|
|
128
|
-
// Create aggregate network based on current partition
|
|
129
|
-
createAggregateNetwork(currentGraph, communities);
|
|
130
|
-
|
|
131
|
-
// Refine partition using aggregate network
|
|
132
|
-
const subsetPartition = refinePartition(currentGraph, communities);
|
|
133
|
-
|
|
134
|
-
// Apply refined partition
|
|
135
|
-
for (const [node, newCommunity] of subsetPartition) {
|
|
136
|
-
communities.set(node, newCommunity);
|
|
117
|
+
continue;
|
|
137
118
|
}
|
|
138
119
|
|
|
139
|
-
|
|
140
|
-
modularity = calculateModularity(currentGraph, communities, degrees, totalWeight, resolution);
|
|
120
|
+
polishing = false;
|
|
141
121
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (!improved) {
|
|
150
|
-
break;
|
|
122
|
+
const aggregated = aggregateLevel(levelGraph, communities);
|
|
123
|
+
for (const [original, levelNode] of placement) {
|
|
124
|
+
const superNode = aggregated.mapping.get(levelNode);
|
|
125
|
+
if (superNode !== undefined) {
|
|
126
|
+
placement.set(original, superNode);
|
|
127
|
+
}
|
|
151
128
|
}
|
|
152
129
|
|
|
153
|
-
|
|
154
|
-
const aggregated = aggregateCommunities(currentGraph, communities);
|
|
155
|
-
if (aggregated.graph.size === currentGraph.size) {
|
|
156
|
-
break;
|
|
157
|
-
} // No aggregation possible
|
|
158
|
-
|
|
159
|
-
// Continue with aggregated network
|
|
160
|
-
const { graph: aggregatedGraph } = aggregated;
|
|
161
|
-
currentGraph = aggregatedGraph;
|
|
162
|
-
communities.clear();
|
|
163
|
-
let communityId = 0;
|
|
164
|
-
for (const node of currentGraph.keys()) {
|
|
165
|
-
communities.set(node, communityId++);
|
|
166
|
-
}
|
|
130
|
+
levelGraph = aggregated.graph;
|
|
167
131
|
}
|
|
168
132
|
|
|
169
|
-
//
|
|
133
|
+
// Renumber communities consecutively, in the order they are first met, so the ids a caller
|
|
134
|
+
// reads are 0..k-1 whatever the levels handed out along the way.
|
|
135
|
+
const renumbered = new Map<number, number>();
|
|
170
136
|
const finalCommunities = new Map<string, number>();
|
|
171
137
|
for (const [node, community] of bestCommunities) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const communityRenumber = new Map<number, number>();
|
|
177
|
-
let newId = 0;
|
|
178
|
-
for (const community of new Set(finalCommunities.values())) {
|
|
179
|
-
communityRenumber.set(community, newId++);
|
|
180
|
-
}
|
|
181
|
-
for (const [node, community] of finalCommunities) {
|
|
182
|
-
const newCommunityId = communityRenumber.get(community);
|
|
183
|
-
if (newCommunityId !== undefined) {
|
|
184
|
-
finalCommunities.set(node, newCommunityId);
|
|
138
|
+
let id = renumbered.get(community);
|
|
139
|
+
if (id === undefined) {
|
|
140
|
+
id = renumbered.size;
|
|
141
|
+
renumbered.set(community, id);
|
|
185
142
|
}
|
|
143
|
+
|
|
144
|
+
finalCommunities.set(node, id);
|
|
186
145
|
}
|
|
187
146
|
|
|
188
147
|
return {
|
|
@@ -278,127 +237,6 @@ function getNeighborCommunities(
|
|
|
278
237
|
return neighborCommunities;
|
|
279
238
|
}
|
|
280
239
|
|
|
281
|
-
/**
|
|
282
|
-
* Calculate modularity gain from moving a node to a community
|
|
283
|
-
* @param node - The node ID to move
|
|
284
|
-
* @param targetCommunity - The target community ID
|
|
285
|
-
* @param graph - Map representation of the graph
|
|
286
|
-
* @param communities - Map of node IDs to community IDs
|
|
287
|
-
* @param degrees - Map of node IDs to their weighted degrees
|
|
288
|
-
* @param totalWeight - Total weight of all edges in the graph
|
|
289
|
-
* @param resolution - Resolution parameter for modularity calculation
|
|
290
|
-
* @returns The modularity gain from moving the node to the target community
|
|
291
|
-
*/
|
|
292
|
-
function calculateModularityGain(
|
|
293
|
-
node: string,
|
|
294
|
-
targetCommunity: number,
|
|
295
|
-
graph: Map<string, Map<string, number>>,
|
|
296
|
-
communities: Map<string, number>,
|
|
297
|
-
degrees: Map<string, number>,
|
|
298
|
-
totalWeight: number,
|
|
299
|
-
resolution: number,
|
|
300
|
-
): number {
|
|
301
|
-
const currentCommunity = communities.get(node);
|
|
302
|
-
const nodeDegree = degrees.get(node);
|
|
303
|
-
if (currentCommunity === undefined || nodeDegree === undefined) {
|
|
304
|
-
return 0;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Weight of edges from node to target community
|
|
308
|
-
let weightToTarget = 0;
|
|
309
|
-
let weightToCurrent = 0;
|
|
310
|
-
|
|
311
|
-
const neighbors = graph.get(node);
|
|
312
|
-
if (neighbors) {
|
|
313
|
-
for (const [neighbor, weight] of neighbors) {
|
|
314
|
-
const neighborCommunity = communities.get(neighbor);
|
|
315
|
-
if (neighborCommunity === undefined) {
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (neighborCommunity === targetCommunity) {
|
|
320
|
-
weightToTarget += weight;
|
|
321
|
-
} else if (neighborCommunity === currentCommunity && neighbor !== node) {
|
|
322
|
-
weightToCurrent += weight;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Calculate community degrees
|
|
328
|
-
let targetDegree = 0;
|
|
329
|
-
let currentDegree = 0;
|
|
330
|
-
|
|
331
|
-
for (const [n, c] of communities) {
|
|
332
|
-
if (c === targetCommunity && n !== node) {
|
|
333
|
-
const deg = degrees.get(n);
|
|
334
|
-
if (deg !== undefined) {
|
|
335
|
-
targetDegree += deg;
|
|
336
|
-
}
|
|
337
|
-
} else if (c === currentCommunity && n !== node) {
|
|
338
|
-
const deg = degrees.get(n);
|
|
339
|
-
if (deg !== undefined) {
|
|
340
|
-
currentDegree += deg;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Modularity gain calculation
|
|
346
|
-
const m2 = 2 * totalWeight;
|
|
347
|
-
const gain =
|
|
348
|
-
(weightToTarget - weightToCurrent) / totalWeight -
|
|
349
|
-
(resolution * nodeDegree * (targetDegree - currentDegree)) / (m2 * m2);
|
|
350
|
-
|
|
351
|
-
return gain;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Create aggregate network where each community becomes a super-node
|
|
356
|
-
* @param graph - Map representation of the graph
|
|
357
|
-
* @param communities - Map of node IDs to community IDs
|
|
358
|
-
* @returns Object with aggregate graph and node-to-community mapping
|
|
359
|
-
*/
|
|
360
|
-
function createAggregateNetwork(
|
|
361
|
-
graph: Map<string, Map<string, number>>,
|
|
362
|
-
communities: Map<string, number>,
|
|
363
|
-
): {
|
|
364
|
-
aggregateGraph: Map<number, Map<number, number>>;
|
|
365
|
-
nodeMapping: Map<string, number>;
|
|
366
|
-
} {
|
|
367
|
-
const aggregateGraph = new Map<number, Map<number, number>>();
|
|
368
|
-
const nodeMapping = new Map<string, number>();
|
|
369
|
-
|
|
370
|
-
// Create mapping from nodes to communities
|
|
371
|
-
for (const [node, community] of communities) {
|
|
372
|
-
nodeMapping.set(node, community);
|
|
373
|
-
if (!aggregateGraph.has(community)) {
|
|
374
|
-
aggregateGraph.set(community, new Map());
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Aggregate edges
|
|
379
|
-
for (const [node, neighbors] of graph) {
|
|
380
|
-
const sourceCommunity = communities.get(node);
|
|
381
|
-
if (sourceCommunity === undefined) {
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
for (const [neighbor, weight] of neighbors) {
|
|
386
|
-
const targetCommunity = communities.get(neighbor);
|
|
387
|
-
if (targetCommunity === undefined) {
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const sourceNeighbors = aggregateGraph.get(sourceCommunity);
|
|
392
|
-
if (sourceNeighbors) {
|
|
393
|
-
const current = sourceNeighbors.get(targetCommunity) ?? 0;
|
|
394
|
-
sourceNeighbors.set(targetCommunity, current + weight);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
return { aggregateGraph, nodeMapping };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
240
|
/**
|
|
403
241
|
* Refine partition (Leiden-specific improvement)
|
|
404
242
|
* Ensures well-connected communities by considering subsets
|
|
@@ -511,67 +349,224 @@ function findConnectedComponents(graph: Map<string, Set<string>>): Set<string>[]
|
|
|
511
349
|
}
|
|
512
350
|
|
|
513
351
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
352
|
+
* What one level of the algorithm knows about its own graph.
|
|
353
|
+
*
|
|
354
|
+
* RECOMPUTED PER LEVEL, which is the half that used to be missing. After the first aggregation
|
|
355
|
+
* every node is a super-node, and scoring the new graph with the old graph's degrees made the
|
|
356
|
+
* second level's modularity a number about a graph that no longer existed. It came out at zero,
|
|
357
|
+
* the loop read that as "no improvement", and the algorithm stopped after one pass.
|
|
358
|
+
*/
|
|
359
|
+
interface LevelWeights {
|
|
360
|
+
/** The weighted degree of each node at this level, self-loops included. */
|
|
361
|
+
degrees: Map<string, number>;
|
|
362
|
+
/** Half the summed degree: the total edge weight m, each undirected edge counted once. */
|
|
363
|
+
totalWeight: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Weigh one level's graph.
|
|
368
|
+
* @param graph - The level's graph, stored with both directions of every edge.
|
|
369
|
+
* @returns The degrees and the total edge weight.
|
|
370
|
+
*/
|
|
371
|
+
function weighLevel(graph: Map<string, Map<string, number>>): LevelWeights {
|
|
372
|
+
const degrees = new Map<string, number>();
|
|
373
|
+
let summed = 0;
|
|
374
|
+
|
|
375
|
+
for (const [node, neighbors] of graph) {
|
|
376
|
+
let degree = 0;
|
|
377
|
+
for (const weight of neighbors.values()) {
|
|
378
|
+
degree += weight;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
degrees.set(node, degree);
|
|
382
|
+
summed += degree;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { degrees, totalWeight: summed / 2 };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* One node per community, numbered from zero in the order the graph lists its nodes.
|
|
390
|
+
* @param graph - The level's graph.
|
|
391
|
+
* @returns The partition every level starts from.
|
|
518
392
|
*/
|
|
519
|
-
function
|
|
393
|
+
function singletonCommunities(graph: Map<string, Map<string, number>>): Map<string, number> {
|
|
394
|
+
const communities = new Map<string, number>();
|
|
395
|
+
let next = 0;
|
|
396
|
+
|
|
397
|
+
for (const node of graph.keys()) {
|
|
398
|
+
communities.set(node, next++);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return communities;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The summed degree of each community's members, which local moving keeps in step with every move.
|
|
406
|
+
* @param communities - The partition.
|
|
407
|
+
* @param degrees - The weighted degree of each node at this level.
|
|
408
|
+
* @returns The summed degree per community.
|
|
409
|
+
*/
|
|
410
|
+
function communityDegrees(communities: Map<string, number>, degrees: Map<string, number>): Map<number, number> {
|
|
411
|
+
const totals = new Map<number, number>();
|
|
412
|
+
|
|
413
|
+
for (const [node, community] of communities) {
|
|
414
|
+
totals.set(community, (totals.get(community) ?? 0) + (degrees.get(node) ?? 0));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return totals;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Move nodes to neighbouring communities until a whole sweep changes nothing.
|
|
422
|
+
*
|
|
423
|
+
* ONE SWEEP IS NOT CONVERGENCE, and treating it as such is what left this algorithm returning
|
|
424
|
+
* partitions it could beat with six single-node moves. A move changes the communities its
|
|
425
|
+
* neighbours are weighed against, so the node visited first is judged against a partition that no
|
|
426
|
+
* longer exists by the time the sweep ends. Repeating until a sweep moves nobody is what makes
|
|
427
|
+
* the answer a local optimum of the objective rather than an artefact of the visiting order.
|
|
428
|
+
* @param graph - The level's graph.
|
|
429
|
+
* @param weights - Its degrees and total edge weight.
|
|
430
|
+
* @param communities - The partition, moved in place.
|
|
431
|
+
* @param random - The seeded generator the visiting order is shuffled with.
|
|
432
|
+
* @param resolution - Higher values favour smaller communities.
|
|
433
|
+
* @param maxPasses - A ceiling on the sweeps, so a pathological graph cannot spin for ever.
|
|
434
|
+
* @returns Whether anything moved at all.
|
|
435
|
+
*/
|
|
436
|
+
function moveNodesLocally(
|
|
520
437
|
graph: Map<string, Map<string, number>>,
|
|
438
|
+
weights: LevelWeights,
|
|
521
439
|
communities: Map<string, number>,
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
|
|
440
|
+
random: () => number,
|
|
441
|
+
resolution: number,
|
|
442
|
+
maxPasses: number,
|
|
443
|
+
): boolean {
|
|
444
|
+
const { degrees, totalWeight } = weights;
|
|
445
|
+
|
|
446
|
+
if (totalWeight === 0) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
528
449
|
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
450
|
+
const totals = communityDegrees(communities, degrees);
|
|
451
|
+
const nodes = [...graph.keys()];
|
|
452
|
+
let movedEver = false;
|
|
453
|
+
// A fresh id for a node that does better on its own, the empty community the paper also offers.
|
|
454
|
+
let nextEmpty = 0;
|
|
455
|
+
for (const community of communities.values()) {
|
|
456
|
+
nextEmpty = Math.max(nextEmpty, community + 1);
|
|
535
457
|
}
|
|
536
458
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
459
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
460
|
+
const order = [...nodes];
|
|
461
|
+
shuffle(order, random);
|
|
462
|
+
let movedThisPass = false;
|
|
463
|
+
|
|
464
|
+
for (const node of order) {
|
|
465
|
+
const current = communities.get(node);
|
|
466
|
+
const degree = degrees.get(node);
|
|
467
|
+
if (current === undefined || degree === undefined) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const neighborCommunities = getNeighborCommunities(node, graph, communities);
|
|
472
|
+
const selfLoop = graph.get(node)?.get(node) ?? 0;
|
|
473
|
+
const weightToCurrent = (neighborCommunities.get(current) ?? 0) - selfLoop;
|
|
474
|
+
const degreeOfCurrent = (totals.get(current) ?? 0) - degree;
|
|
475
|
+
|
|
476
|
+
let bestCommunity = current;
|
|
477
|
+
let bestGain = 0;
|
|
478
|
+
|
|
479
|
+
// Leaving for an empty community: the target has no weight and no degree.
|
|
480
|
+
if (degreeOfCurrent > 0) {
|
|
481
|
+
const alone =
|
|
482
|
+
-weightToCurrent / totalWeight +
|
|
483
|
+
(resolution * degree * degreeOfCurrent) / (2 * totalWeight * totalWeight);
|
|
484
|
+
if (alone > bestGain) {
|
|
485
|
+
bestGain = alone;
|
|
486
|
+
bestCommunity = nextEmpty;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
for (const [community, weightToTarget] of neighborCommunities) {
|
|
491
|
+
if (community === current) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// The standard modularity gain for moving one node out of its community and into
|
|
496
|
+
// another: what the move adds to the edges inside a community, less what it adds
|
|
497
|
+
// to what a random graph of the same degrees would have had there.
|
|
498
|
+
const gain =
|
|
499
|
+
(weightToTarget - weightToCurrent) / totalWeight -
|
|
500
|
+
(resolution * degree * ((totals.get(community) ?? 0) - degreeOfCurrent)) /
|
|
501
|
+
(2 * totalWeight * totalWeight);
|
|
502
|
+
|
|
503
|
+
if (gain > bestGain) {
|
|
504
|
+
bestGain = gain;
|
|
505
|
+
bestCommunity = community;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (bestCommunity !== current) {
|
|
510
|
+
if (bestCommunity === nextEmpty) {
|
|
511
|
+
nextEmpty++;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
communities.set(node, bestCommunity);
|
|
515
|
+
totals.set(current, (totals.get(current) ?? 0) - degree);
|
|
516
|
+
totals.set(bestCommunity, (totals.get(bestCommunity) ?? 0) + degree);
|
|
517
|
+
movedThisPass = true;
|
|
518
|
+
movedEver = true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!movedThisPass) {
|
|
523
|
+
break;
|
|
542
524
|
}
|
|
543
525
|
}
|
|
544
526
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
527
|
+
return movedEver;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Collapse each community into one node, carrying its internal weight as a self-loop.
|
|
532
|
+
*
|
|
533
|
+
* THE SELF-LOOP IS THE POINT. Without it the aggregated graph forgets every edge inside a
|
|
534
|
+
* community, so the next level scores a graph with no internal weight anywhere and every
|
|
535
|
+
* partition of it looks equally bad. Both directions of every internal edge land in the same
|
|
536
|
+
* entry, which is exactly the doubled internal weight the modularity sum wants.
|
|
537
|
+
* @param graph - The level's graph.
|
|
538
|
+
* @param communities - The partition to collapse.
|
|
539
|
+
* @returns The next level's graph, and where each node of this one went.
|
|
540
|
+
*/
|
|
541
|
+
function aggregateLevel(
|
|
542
|
+
graph: Map<string, Map<string, number>>,
|
|
543
|
+
communities: Map<string, number>,
|
|
544
|
+
): { graph: Map<string, Map<string, number>>; mapping: Map<string, string> } {
|
|
545
|
+
const aggregated = new Map<string, Map<string, number>>();
|
|
546
|
+
const mapping = new Map<string, string>();
|
|
547
|
+
|
|
548
|
+
for (const [node, community] of communities) {
|
|
549
|
+
const superNode = `c${String(community)}`;
|
|
550
|
+
mapping.set(node, superNode);
|
|
551
|
+
if (!aggregated.has(superNode)) {
|
|
552
|
+
aggregated.set(superNode, new Map());
|
|
550
553
|
}
|
|
554
|
+
}
|
|
551
555
|
|
|
552
|
-
|
|
553
|
-
|
|
556
|
+
for (const [node, neighbors] of graph) {
|
|
557
|
+
const source = mapping.get(node);
|
|
558
|
+
const row = source === undefined ? undefined : aggregated.get(source);
|
|
559
|
+
if (row === undefined) {
|
|
554
560
|
continue;
|
|
555
561
|
}
|
|
556
562
|
|
|
557
563
|
for (const [neighbor, weight] of neighbors) {
|
|
558
|
-
const
|
|
559
|
-
if (
|
|
560
|
-
continue;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const targetSuper = communityNodes.get(targetCommunity);
|
|
564
|
-
if (targetSuper === undefined) {
|
|
564
|
+
const target = mapping.get(neighbor);
|
|
565
|
+
if (target === undefined) {
|
|
565
566
|
continue;
|
|
566
567
|
}
|
|
567
568
|
|
|
568
|
-
|
|
569
|
-
const sourceNeighbors = aggregated.get(sourceSuper);
|
|
570
|
-
if (sourceNeighbors) {
|
|
571
|
-
const current = sourceNeighbors.get(targetSuper) ?? 0;
|
|
572
|
-
sourceNeighbors.set(targetSuper, current + weight);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
569
|
+
row.set(target, (row.get(target) ?? 0) + weight);
|
|
575
570
|
}
|
|
576
571
|
}
|
|
577
572
|
|