@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
|
@@ -4,13 +4,18 @@ import type { CommunityResult, NodeId } from "../../types/index.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Optimized Louvain community detection algorithm with early pruning and threshold cycling
|
|
6
6
|
*
|
|
7
|
+
* Runs the full multilevel method of Blondel et al. (local moving, then aggregating each community
|
|
8
|
+
* into one node, repeated until nothing moves) over typed arrays indexed 0..n-1.
|
|
9
|
+
*
|
|
7
10
|
* Key optimizations:
|
|
8
|
-
* -
|
|
11
|
+
* - Typed-array adjacency (CSR) instead of per-edge map lookups
|
|
12
|
+
* - Leaf node pruning: skip a degree-1 node already in its neighbour's community
|
|
9
13
|
* - Importance ordering: Process high-impact nodes first
|
|
10
|
-
* - Threshold cycling: Adaptive
|
|
14
|
+
* - Threshold cycling: Adaptive move thresholds, relative to each node's largest possible gain
|
|
11
15
|
* - Early termination: Stop when changes become insignificant
|
|
12
16
|
*
|
|
13
|
-
*
|
|
17
|
+
* The graph is treated as undirected: a directed edge counts as an undirected edge of its weight.
|
|
18
|
+
* A self-loop of weight w adds 2w to its node's degree, the standard convention (NetworkX too).
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
interface OptimizedLouvainOptions {
|
|
@@ -19,11 +24,11 @@ interface OptimizedLouvainOptions {
|
|
|
19
24
|
*/
|
|
20
25
|
resolution?: number;
|
|
21
26
|
/**
|
|
22
|
-
* Maximum
|
|
27
|
+
* Maximum local-moving sweeps per level (default: 100)
|
|
23
28
|
*/
|
|
24
29
|
maxIterations?: number;
|
|
25
30
|
/**
|
|
26
|
-
* Convergence tolerance (default: 1e-6)
|
|
31
|
+
* Convergence tolerance on a sweep's modularity gain (default: 1e-6)
|
|
27
32
|
*/
|
|
28
33
|
tolerance?: number;
|
|
29
34
|
/**
|
|
@@ -35,7 +40,7 @@ interface OptimizedLouvainOptions {
|
|
|
35
40
|
*/
|
|
36
41
|
importanceOrdering?: boolean;
|
|
37
42
|
/**
|
|
38
|
-
* Base
|
|
43
|
+
* Base move threshold, as a fraction of a node's largest possible modularity gain (default: 0.01)
|
|
39
44
|
*/
|
|
40
45
|
pruningThreshold?: number;
|
|
41
46
|
/**
|
|
@@ -50,16 +55,34 @@ interface PruningStats {
|
|
|
50
55
|
stableNodesPruned: number;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
/** One level of the multilevel method: an undirected weighted graph in CSR form. */
|
|
59
|
+
interface Level {
|
|
60
|
+
n: number;
|
|
61
|
+
/** Row offsets into `targets` / `weights`, length n + 1. Each edge appears in both rows. */
|
|
62
|
+
offsets: Int32Array;
|
|
63
|
+
targets: Int32Array;
|
|
64
|
+
weights: Float64Array;
|
|
65
|
+
/** Weight of the edges inside each node (self-loops), each edge counted once. */
|
|
66
|
+
self: Float64Array;
|
|
67
|
+
/** Weighted degree of each node (a self-loop counts twice). */
|
|
68
|
+
degree: Float64Array;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface LocalMovingOptions {
|
|
72
|
+
resolution: number;
|
|
73
|
+
maxIterations: number;
|
|
74
|
+
tolerance: number;
|
|
75
|
+
pruneLeaves: boolean;
|
|
76
|
+
importanceOrdering: boolean;
|
|
77
|
+
pruningThreshold: number;
|
|
78
|
+
thresholdCycling: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
/**
|
|
54
82
|
* Optimized Louvain implementation with early pruning and threshold cycling
|
|
55
83
|
*/
|
|
56
84
|
export class OptimizedLouvain {
|
|
57
85
|
private graph: Graph;
|
|
58
|
-
private communities: Map<NodeId, number>;
|
|
59
|
-
private communityWeights: Map<number, number>;
|
|
60
|
-
private nodeWeights: Map<NodeId, number>;
|
|
61
|
-
private nodeDegrees: Map<NodeId, number>;
|
|
62
|
-
private totalWeight: number;
|
|
63
86
|
private pruningStats: PruningStats;
|
|
64
87
|
|
|
65
88
|
/**
|
|
@@ -68,11 +91,6 @@ export class OptimizedLouvain {
|
|
|
68
91
|
*/
|
|
69
92
|
constructor(graph: Graph) {
|
|
70
93
|
this.graph = graph;
|
|
71
|
-
this.communities = new Map();
|
|
72
|
-
this.communityWeights = new Map();
|
|
73
|
-
this.nodeWeights = new Map();
|
|
74
|
-
this.nodeDegrees = new Map();
|
|
75
|
-
this.totalWeight = 0;
|
|
76
94
|
this.pruningStats = {
|
|
77
95
|
leafNodesPruned: 0,
|
|
78
96
|
lowDegreeNodesPruned: 0,
|
|
@@ -83,383 +101,355 @@ export class OptimizedLouvain {
|
|
|
83
101
|
/**
|
|
84
102
|
* Run optimized Louvain algorithm
|
|
85
103
|
* @param options - Algorithm configuration options
|
|
86
|
-
* @returns Community detection result with communities, modularity, and iterations
|
|
104
|
+
* @returns Community detection result with communities, modularity, and iterations (levels)
|
|
87
105
|
*/
|
|
88
106
|
public detectCommunities(options: OptimizedLouvainOptions = {}): CommunityResult {
|
|
89
|
-
const {
|
|
90
|
-
resolution
|
|
91
|
-
maxIterations
|
|
92
|
-
tolerance
|
|
93
|
-
pruneLeaves
|
|
94
|
-
importanceOrdering
|
|
95
|
-
pruningThreshold
|
|
96
|
-
thresholdCycling
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Initialize
|
|
100
|
-
this.initialize();
|
|
101
|
-
let modularity = this.calculateModularity(resolution);
|
|
102
|
-
let iteration = 0;
|
|
103
|
-
let improved = true;
|
|
104
|
-
|
|
105
|
-
while (iteration < maxIterations && improved) {
|
|
106
|
-
// Get nodes in optimal processing order
|
|
107
|
-
const orderedNodes = importanceOrdering
|
|
108
|
-
? this.getNodesInImportanceOrder()
|
|
109
|
-
: Array.from(this.graph.nodes()).map((n) => n.id);
|
|
110
|
-
|
|
111
|
-
// Apply adaptive threshold
|
|
112
|
-
const threshold = thresholdCycling ? this.getAdaptiveThreshold(iteration, pruningThreshold) : 0;
|
|
113
|
-
|
|
114
|
-
// Perform local optimization
|
|
115
|
-
improved = this.performLocalMoving(orderedNodes, {
|
|
116
|
-
pruneLeaves,
|
|
117
|
-
threshold,
|
|
118
|
-
resolution,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
if (improved) {
|
|
122
|
-
const newModularity = this.calculateModularity(resolution);
|
|
123
|
-
|
|
124
|
-
// Check convergence
|
|
125
|
-
if (Math.abs(newModularity - modularity) < tolerance) {
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
107
|
+
const settings: LocalMovingOptions = {
|
|
108
|
+
resolution: options.resolution ?? 1.0,
|
|
109
|
+
maxIterations: options.maxIterations ?? 100,
|
|
110
|
+
tolerance: options.tolerance ?? 1e-6,
|
|
111
|
+
pruneLeaves: options.pruneLeaves ?? true,
|
|
112
|
+
importanceOrdering: options.importanceOrdering ?? true,
|
|
113
|
+
pruningThreshold: options.pruningThreshold ?? 0.01,
|
|
114
|
+
thresholdCycling: options.thresholdCycling ?? true,
|
|
115
|
+
};
|
|
128
116
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
117
|
+
const { level: base, ids } = this.buildLevel();
|
|
118
|
+
// membership[i] = the current level's node that original node i has been folded into
|
|
119
|
+
const membership = new Int32Array(base.n);
|
|
120
|
+
for (let i = 0; i < base.n; i++) {
|
|
121
|
+
membership[i] = i;
|
|
132
122
|
}
|
|
133
123
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!
|
|
139
|
-
|
|
124
|
+
let level = base;
|
|
125
|
+
let iterations = 0;
|
|
126
|
+
while (level.n > 1) {
|
|
127
|
+
const { community, count, moved } = this.localMoving(level, settings);
|
|
128
|
+
if (!moved) {
|
|
129
|
+
break;
|
|
140
130
|
}
|
|
141
131
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
iterations++;
|
|
133
|
+
for (let i = 0; i < membership.length; i++) {
|
|
134
|
+
membership[i] = community[membership[i]];
|
|
145
135
|
}
|
|
136
|
+
|
|
137
|
+
level = aggregate(level, community, count);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const groups: NodeId[][] = Array.from({ length: level.n }, () => []);
|
|
141
|
+
for (let i = 0; i < membership.length; i++) {
|
|
142
|
+
groups[membership[i]].push(ids[i]);
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
return {
|
|
149
|
-
communities:
|
|
150
|
-
modularity,
|
|
151
|
-
iterations
|
|
146
|
+
communities: groups.filter((group) => group.length > 0),
|
|
147
|
+
modularity: levelModularity(level, settings.resolution),
|
|
148
|
+
iterations,
|
|
152
149
|
};
|
|
153
150
|
}
|
|
154
151
|
|
|
155
152
|
/**
|
|
156
|
-
*
|
|
153
|
+
* Get pruning statistics
|
|
154
|
+
* @returns Statistics about nodes pruned during optimization
|
|
157
155
|
*/
|
|
158
|
-
|
|
159
|
-
|
|
156
|
+
public getPruningStats(): PruningStats {
|
|
157
|
+
return { ...this.pruningStats };
|
|
158
|
+
}
|
|
160
159
|
|
|
161
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Index the graph's nodes 0..n-1 and build the level-0 CSR adjacency
|
|
162
|
+
* @returns The level-0 graph and the node id at each index
|
|
163
|
+
*/
|
|
164
|
+
private buildLevel(): { level: Level; ids: NodeId[] } {
|
|
165
|
+
const ids: NodeId[] = [];
|
|
166
|
+
const index = new Map<NodeId, number>();
|
|
162
167
|
for (const node of this.graph.nodes()) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let nodeWeight = 0;
|
|
167
|
-
let degree = 0;
|
|
168
|
+
index.set(node.id, ids.length);
|
|
169
|
+
ids.push(node.id);
|
|
170
|
+
}
|
|
168
171
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
const n = ids.length;
|
|
173
|
+
const edges: [number, number, number][] = [];
|
|
174
|
+
const counts = new Int32Array(n + 1);
|
|
175
|
+
const self = new Float64Array(n);
|
|
176
|
+
const degree = new Float64Array(n);
|
|
177
|
+
|
|
178
|
+
for (const edge of this.graph.edges()) {
|
|
179
|
+
const s = index.get(edge.source);
|
|
180
|
+
const t = index.get(edge.target);
|
|
181
|
+
if (s === undefined || t === undefined) {
|
|
182
|
+
continue;
|
|
174
183
|
}
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
nodeWeight += weight;
|
|
183
|
-
degree++;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
185
|
+
const w = edge.weight ?? 1;
|
|
186
|
+
degree[s] += w;
|
|
187
|
+
degree[t] += w;
|
|
188
|
+
if (s === t) {
|
|
189
|
+
self[s] += w;
|
|
190
|
+
continue;
|
|
186
191
|
}
|
|
187
192
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
this.totalWeight += nodeWeight;
|
|
192
|
-
|
|
193
|
-
communityId++;
|
|
193
|
+
edges.push([s, t, w]);
|
|
194
|
+
counts[s + 1]++;
|
|
195
|
+
counts[t + 1]++;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
|
|
197
|
-
// For undirected graphs, each edge is counted twice from both endpoints
|
|
198
|
-
this.totalWeight = this.totalWeight / 2;
|
|
198
|
+
return { level: buildCsr(n, counts, edges, self, degree), ids };
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
202
|
+
* Move each node to the neighbouring community with the best modularity gain, until no move
|
|
203
|
+
* improves modularity
|
|
204
|
+
* @param level - The graph at this level
|
|
205
|
+
* @param options - Local moving options
|
|
206
|
+
* @returns Each node's community (numbered 0..count-1), the count, and whether any node moved
|
|
204
207
|
*/
|
|
205
|
-
private
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
private localMoving(
|
|
209
|
+
level: Level,
|
|
210
|
+
options: LocalMovingOptions,
|
|
211
|
+
): { community: Int32Array; count: number; moved: boolean } {
|
|
212
|
+
const { n, offsets, targets, weights, degree } = level;
|
|
213
|
+
const { resolution, maxIterations, tolerance, pruneLeaves } = options;
|
|
214
|
+
|
|
215
|
+
const twoM = degree.reduce((sum, d) => sum + d, 0);
|
|
216
|
+
const community = new Int32Array(n);
|
|
217
|
+
const total = new Float64Array(n);
|
|
218
|
+
for (let i = 0; i < n; i++) {
|
|
219
|
+
community[i] = i;
|
|
220
|
+
total[i] = degree[i];
|
|
214
221
|
}
|
|
215
222
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.map(([nodeId]) => nodeId);
|
|
220
|
-
}
|
|
223
|
+
if (twoM === 0) {
|
|
224
|
+
return { community, count: n, moved: false };
|
|
225
|
+
}
|
|
221
226
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
let hasChanged = true;
|
|
242
|
-
|
|
243
|
-
while (hasChanged) {
|
|
244
|
-
hasChanged = false;
|
|
245
|
-
|
|
246
|
-
for (const nodeId of nodes) {
|
|
247
|
-
// Early pruning: skip leaf nodes
|
|
248
|
-
if (pruneLeaves && this.isLeafNode(nodeId)) {
|
|
227
|
+
const order = this.nodeOrder(level, options.importanceOrdering);
|
|
228
|
+
// Scratch: weight from the current node to each community, and the communities touched
|
|
229
|
+
const toCommunity = new Float64Array(n);
|
|
230
|
+
const touched = new Int32Array(n);
|
|
231
|
+
|
|
232
|
+
let moved = false;
|
|
233
|
+
let useThreshold = options.thresholdCycling;
|
|
234
|
+
for (let sweep = 0; sweep < maxIterations; sweep++) {
|
|
235
|
+
// The threshold is a fraction of the most node i could gain from any move (all of its
|
|
236
|
+
// weight landing inside the target, k_i), so it scales with the graph
|
|
237
|
+
const threshold = useThreshold ? options.pruningThreshold * Math.pow(0.5, sweep / 10) : 0;
|
|
238
|
+
let sweepMoved = false;
|
|
239
|
+
let sweepGain = 0;
|
|
240
|
+
|
|
241
|
+
for (const i of order) {
|
|
242
|
+
const start = offsets[i];
|
|
243
|
+
const end = offsets[i + 1];
|
|
244
|
+
if (pruneLeaves && end - start === 1 && community[targets[start]] === community[i]) {
|
|
245
|
+
// A leaf's only move is to its one neighbour's community, and it is there
|
|
249
246
|
this.pruningStats.leafNodesPruned++;
|
|
250
247
|
continue;
|
|
251
248
|
}
|
|
252
249
|
|
|
253
|
-
|
|
254
|
-
const neighborCommunities = this.getNeighborCommunities(nodeId);
|
|
255
|
-
|
|
256
|
-
// Skip isolated nodes
|
|
257
|
-
if (neighborCommunities.size === 0) {
|
|
250
|
+
if (end === start) {
|
|
258
251
|
continue;
|
|
259
252
|
}
|
|
260
253
|
|
|
261
|
-
|
|
262
|
-
let
|
|
263
|
-
let
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
254
|
+
const current = community[i];
|
|
255
|
+
let touchedCount = 0;
|
|
256
|
+
for (let e = start; e < end; e++) {
|
|
257
|
+
const c = community[targets[e]];
|
|
258
|
+
if (toCommunity[c] === 0) {
|
|
259
|
+
touched[touchedCount++] = c;
|
|
260
|
+
}
|
|
267
261
|
|
|
268
|
-
|
|
269
|
-
|
|
262
|
+
toCommunity[c] += weights[e];
|
|
263
|
+
}
|
|
270
264
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
265
|
+
// Gains are in units of m * dQ: w(i, c) - resolution * tot(c) * k_i / 2m
|
|
266
|
+
const k = degree[i];
|
|
267
|
+
total[current] -= k;
|
|
268
|
+
const stayGain = toCommunity[current] - (resolution * total[current] * k) / twoM;
|
|
269
|
+
let best = current;
|
|
270
|
+
let bestGain = stayGain;
|
|
271
|
+
const minImprovement = threshold * k;
|
|
272
|
+
|
|
273
|
+
for (let t = 0; t < touchedCount; t++) {
|
|
274
|
+
const c = touched[t];
|
|
275
|
+
if (c !== current) {
|
|
276
|
+
const gain = toCommunity[c] - (resolution * total[c] * k) / twoM;
|
|
277
|
+
if (gain > bestGain + minImprovement) {
|
|
278
|
+
bestGain = gain;
|
|
279
|
+
best = c;
|
|
280
|
+
}
|
|
275
281
|
}
|
|
276
|
-
}
|
|
277
282
|
|
|
278
|
-
|
|
279
|
-
const currentGain = this.calculateModularityGain(nodeId, currentCommunity, resolution);
|
|
280
|
-
if (currentGain > bestGain + threshold) {
|
|
281
|
-
bestGain = currentGain;
|
|
282
|
-
bestCommunity = currentCommunity;
|
|
283
|
+
toCommunity[c] = 0;
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
|
|
286
|
-
|
|
286
|
+
total[best] += k;
|
|
287
|
+
if (best !== current) {
|
|
288
|
+
community[i] = best;
|
|
289
|
+
sweepMoved = true;
|
|
290
|
+
sweepGain += (bestGain - stayGain) / (twoM / 2);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
287
293
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
moved ||= sweepMoved;
|
|
295
|
+
if (!sweepMoved || sweepGain < tolerance) {
|
|
296
|
+
// A threshold can stall moves that are still worth making: finish without one
|
|
297
|
+
if (useThreshold) {
|
|
298
|
+
useThreshold = false;
|
|
299
|
+
continue;
|
|
292
300
|
}
|
|
301
|
+
|
|
302
|
+
break;
|
|
293
303
|
}
|
|
294
304
|
}
|
|
295
305
|
|
|
296
|
-
|
|
297
|
-
|
|
306
|
+
// Renumber the communities 0..count-1
|
|
307
|
+
const renumber = new Int32Array(n).fill(-1);
|
|
308
|
+
let count = 0;
|
|
309
|
+
for (let i = 0; i < n; i++) {
|
|
310
|
+
const c = community[i];
|
|
311
|
+
if (renumber[c] === -1) {
|
|
312
|
+
renumber[c] = count++;
|
|
313
|
+
}
|
|
298
314
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
* @param nodeId - The node ID to check
|
|
302
|
-
* @returns True if the node has degree 1, false otherwise
|
|
303
|
-
*/
|
|
304
|
-
private isLeafNode(nodeId: NodeId): boolean {
|
|
305
|
-
const degree = this.nodeDegrees.get(nodeId) ?? 0;
|
|
306
|
-
return degree === 1;
|
|
307
|
-
}
|
|
315
|
+
community[i] = renumber[c];
|
|
316
|
+
}
|
|
308
317
|
|
|
309
|
-
|
|
310
|
-
* Get adaptive threshold that decreases with iterations
|
|
311
|
-
* @param iteration - Current iteration number
|
|
312
|
-
* @param baseThreshold - Base threshold value to scale
|
|
313
|
-
* @returns Adaptive threshold value that decays over iterations
|
|
314
|
-
*/
|
|
315
|
-
private getAdaptiveThreshold(iteration: number, baseThreshold: number): number {
|
|
316
|
-
// Exponentially decay threshold with iterations
|
|
317
|
-
// This allows coarse movements early and fine-tuning later
|
|
318
|
-
return baseThreshold * Math.pow(0.5, iteration / 10);
|
|
318
|
+
return { community, count, moved };
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
/**
|
|
322
|
-
*
|
|
323
|
-
* @param
|
|
324
|
-
* @param
|
|
325
|
-
* @
|
|
326
|
-
* @returns The modularity gain from moving the node
|
|
322
|
+
* Order nodes for local moving, by importance (degree * log(1 + weighted degree)) if asked
|
|
323
|
+
* @param level - The graph at this level
|
|
324
|
+
* @param importanceOrdering - Whether to sort by importance
|
|
325
|
+
* @returns Node indices in processing order
|
|
327
326
|
*/
|
|
328
|
-
private
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
let weightToTarget = 0;
|
|
333
|
-
|
|
334
|
-
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
|
|
335
|
-
if (this.communities.get(neighbor) === targetCommunity) {
|
|
336
|
-
const edge = this.graph.getEdge(nodeId, neighbor);
|
|
337
|
-
weightToTarget += edge?.weight ?? 1;
|
|
338
|
-
}
|
|
327
|
+
private nodeOrder(level: Level, importanceOrdering: boolean): Int32Array {
|
|
328
|
+
const order = new Int32Array(level.n);
|
|
329
|
+
for (let i = 0; i < level.n; i++) {
|
|
330
|
+
order[i] = i;
|
|
339
331
|
}
|
|
340
332
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
|
|
344
|
-
if (this.communities.get(neighbor) === targetCommunity && !this.graph.hasEdge(nodeId, neighbor)) {
|
|
345
|
-
const edge = this.graph.getEdge(neighbor, nodeId);
|
|
346
|
-
weightToTarget += edge?.weight ?? 1;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
333
|
+
if (!importanceOrdering) {
|
|
334
|
+
return order;
|
|
349
335
|
}
|
|
350
336
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const gain =
|
|
356
|
-
(weightToTarget - (resolution * nodeWeight * targetWeight) / (2 * this.totalWeight)) / this.totalWeight;
|
|
337
|
+
const importance = new Float64Array(level.n);
|
|
338
|
+
for (let i = 0; i < level.n; i++) {
|
|
339
|
+
importance[i] = (level.offsets[i + 1] - level.offsets[i]) * Math.log(1 + level.degree[i]);
|
|
340
|
+
}
|
|
357
341
|
|
|
358
|
-
return
|
|
342
|
+
return order.sort((a, b) => importance[b] - importance[a]);
|
|
359
343
|
}
|
|
344
|
+
}
|
|
360
345
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Build a CSR level from an undirected edge list (each edge listed once, no self-loops)
|
|
348
|
+
* @param n - Node count
|
|
349
|
+
* @param counts - counts[i + 1] = number of edges incident to node i
|
|
350
|
+
* @param edges - The edges as [source, target, weight]
|
|
351
|
+
* @param self - Self-loop weight of each node
|
|
352
|
+
* @param degree - Weighted degree of each node
|
|
353
|
+
* @returns The level
|
|
354
|
+
*/
|
|
355
|
+
function buildCsr(
|
|
356
|
+
n: number,
|
|
357
|
+
counts: Int32Array,
|
|
358
|
+
edges: [number, number, number][],
|
|
359
|
+
self: Float64Array,
|
|
360
|
+
degree: Float64Array,
|
|
361
|
+
): Level {
|
|
362
|
+
const offsets = counts;
|
|
363
|
+
for (let i = 0; i < n; i++) {
|
|
364
|
+
offsets[i + 1] += offsets[i];
|
|
370
365
|
}
|
|
371
366
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
this.communities.set(nodeId, community);
|
|
367
|
+
const targets = new Int32Array(offsets[n]);
|
|
368
|
+
const weights = new Float64Array(offsets[n]);
|
|
369
|
+
const fill = offsets.slice(0, n);
|
|
370
|
+
for (const [s, t, w] of edges) {
|
|
371
|
+
targets[fill[s]] = t;
|
|
372
|
+
weights[fill[s]++] = w;
|
|
373
|
+
targets[fill[t]] = s;
|
|
374
|
+
weights[fill[t]++] = w;
|
|
381
375
|
}
|
|
382
376
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
* @param nodeId - The node ID to find neighbor communities for
|
|
386
|
-
* @returns Set of community IDs that neighbors belong to
|
|
387
|
-
*/
|
|
388
|
-
private getNeighborCommunities(nodeId: NodeId): Set<number> {
|
|
389
|
-
const communities = new Set<number>();
|
|
390
|
-
|
|
391
|
-
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
|
|
392
|
-
const community = this.communities.get(neighbor);
|
|
393
|
-
if (community !== undefined) {
|
|
394
|
-
communities.add(community);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// For undirected graphs, also check incoming edges
|
|
399
|
-
if (!this.graph.isDirected) {
|
|
400
|
-
for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
|
|
401
|
-
const community = this.communities.get(neighbor);
|
|
402
|
-
if (community !== undefined) {
|
|
403
|
-
communities.add(community);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
377
|
+
return { n, offsets, targets, weights, self, degree };
|
|
378
|
+
}
|
|
407
379
|
|
|
408
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Fold each community into one node: edges between communities are summed, edges inside a
|
|
382
|
+
* community become its self-loop
|
|
383
|
+
* @param level - The graph at this level
|
|
384
|
+
* @param community - Each node's community, numbered 0..count-1
|
|
385
|
+
* @param count - Number of communities
|
|
386
|
+
* @returns The next level
|
|
387
|
+
*/
|
|
388
|
+
function aggregate(level: Level, community: Int32Array, count: number): Level {
|
|
389
|
+
const self = new Float64Array(count);
|
|
390
|
+
const degree = new Float64Array(count);
|
|
391
|
+
const members: number[][] = Array.from({ length: count }, () => []);
|
|
392
|
+
for (let i = 0; i < level.n; i++) {
|
|
393
|
+
const c = community[i];
|
|
394
|
+
members[c].push(i);
|
|
395
|
+
self[c] += level.self[i];
|
|
396
|
+
degree[c] += level.degree[i];
|
|
409
397
|
}
|
|
410
398
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
399
|
+
const edges: [number, number, number][] = [];
|
|
400
|
+
const counts = new Int32Array(count + 1);
|
|
401
|
+
const toCommunity = new Float64Array(count);
|
|
402
|
+
const touched: number[] = [];
|
|
403
|
+
for (let c = 0; c < count; c++) {
|
|
404
|
+
for (const i of members[c]) {
|
|
405
|
+
for (let e = level.offsets[i]; e < level.offsets[i + 1]; e++) {
|
|
406
|
+
const d = community[level.targets[e]];
|
|
407
|
+
if (d === c) {
|
|
408
|
+
// Seen once from each endpoint
|
|
409
|
+
self[c] += level.weights[e] / 2;
|
|
410
|
+
} else if (d > c) {
|
|
411
|
+
// Keep each inter-community edge once, from its lower-numbered side
|
|
412
|
+
if (toCommunity[d] === 0) {
|
|
413
|
+
touched.push(d);
|
|
414
|
+
}
|
|
425
415
|
|
|
426
|
-
|
|
427
|
-
for (const node of this.graph.nodes()) {
|
|
428
|
-
const nodeId = node.id;
|
|
429
|
-
const community = this.communities.get(nodeId) ?? 0;
|
|
430
|
-
|
|
431
|
-
for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
|
|
432
|
-
if (this.communities.get(neighbor) === community) {
|
|
433
|
-
const edge = this.graph.getEdge(nodeId, neighbor);
|
|
434
|
-
const weight = edge?.weight ?? 1;
|
|
435
|
-
communityInternalWeights.set(community, (communityInternalWeights.get(community) ?? 0) + weight);
|
|
416
|
+
toCommunity[d] += level.weights[e];
|
|
436
417
|
}
|
|
437
418
|
}
|
|
438
419
|
}
|
|
439
420
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const aTotal = communityWeight;
|
|
446
|
-
|
|
447
|
-
// Modularity formula: sum of (fraction of edges within community - expected fraction)
|
|
448
|
-
const actualFraction = aIn / this.totalWeight;
|
|
449
|
-
const expectedFraction = resolution * Math.pow(aTotal / (2 * this.totalWeight), 2);
|
|
450
|
-
modularity += actualFraction - expectedFraction;
|
|
421
|
+
for (const d of touched) {
|
|
422
|
+
edges.push([c, d, toCommunity[d]]);
|
|
423
|
+
counts[c + 1]++;
|
|
424
|
+
counts[d + 1]++;
|
|
425
|
+
toCommunity[d] = 0;
|
|
451
426
|
}
|
|
452
427
|
|
|
453
|
-
|
|
428
|
+
touched.length = 0;
|
|
454
429
|
}
|
|
455
430
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
431
|
+
return buildCsr(count, counts, edges, self, degree);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Modularity of the partition that puts each node of `level` in its own community
|
|
436
|
+
* @param level - The graph at this level
|
|
437
|
+
* @param resolution - Resolution parameter
|
|
438
|
+
* @returns Q = sum over nodes c of [ self(c) / m - resolution * (k_c / 2m)^2 ]
|
|
439
|
+
*/
|
|
440
|
+
function levelModularity(level: Level, resolution: number): number {
|
|
441
|
+
const twoM = level.degree.reduce((sum, d) => sum + d, 0);
|
|
442
|
+
if (twoM === 0) {
|
|
443
|
+
return 0;
|
|
462
444
|
}
|
|
445
|
+
|
|
446
|
+
let modularity = 0;
|
|
447
|
+
for (let c = 0; c < level.n; c++) {
|
|
448
|
+
const share = level.degree[c] / twoM;
|
|
449
|
+
modularity += (2 * level.self[c]) / twoM - resolution * share * share;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return modularity;
|
|
463
453
|
}
|
|
464
454
|
|
|
465
455
|
/**
|