@graphty/algorithms 1.1.0 → 1.2.0
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 +464 -33
- package/dist/src/clustering/spectral.d.ts.map +1 -1
- package/dist/src/clustering/spectral.js +123 -19
- package/dist/src/clustering/spectral.js.map +1 -1
- 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/dist/src/research/grsbm.d.ts +83 -0
- package/dist/src/research/grsbm.d.ts.map +1 -0
- package/dist/src/research/grsbm.js +411 -0
- package/dist/src/research/grsbm.js.map +1 -0
- package/dist/src/research/index.d.ts +12 -0
- package/dist/src/research/index.d.ts.map +1 -0
- package/dist/src/research/index.js +14 -0
- package/dist/src/research/index.js.map +1 -0
- package/dist/src/research/sync.d.ts +49 -0
- package/dist/src/research/sync.d.ts.map +1 -0
- package/dist/src/research/sync.js +342 -0
- package/dist/src/research/sync.js.map +1 -0
- package/dist/src/research/terahac.d.ts +63 -0
- package/dist/src/research/terahac.d.ts.map +1 -0
- package/dist/src/research/terahac.js +375 -0
- package/dist/src/research/terahac.js.map +1 -0
- package/package.json +11 -2
- package/src/clustering/spectral.ts +147 -20
- package/src/index.ts +3 -0
- package/src/research/grsbm.ts +589 -0
- package/src/research/index.ts +17 -0
- package/src/research/sync.ts +469 -0
- package/src/research/terahac.ts +506 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import type {Graph} from "../core/graph.js";
|
|
2
|
+
import type {NodeId} from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for the TeraHAC (Hierarchical Agglomerative Clustering) algorithm
|
|
6
|
+
*/
|
|
7
|
+
export interface TeraHACConfig {
|
|
8
|
+
/** Linkage criterion: 'single', 'complete', 'average', 'ward' */
|
|
9
|
+
linkage?: "single" | "complete" | "average" | "ward";
|
|
10
|
+
/** Number of clusters to stop at (optional) */
|
|
11
|
+
numClusters?: number;
|
|
12
|
+
/** Distance threshold to stop clustering */
|
|
13
|
+
distanceThreshold?: number;
|
|
14
|
+
/** Maximum number of nodes to process efficiently */
|
|
15
|
+
maxNodes?: number;
|
|
16
|
+
/** Use graph structure for distance calculation */
|
|
17
|
+
useGraphDistance?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Represents a node in the dendrogram/cluster hierarchy
|
|
22
|
+
*/
|
|
23
|
+
export interface ClusterNode {
|
|
24
|
+
/** Unique identifier for this cluster */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Node IDs in this cluster */
|
|
27
|
+
members: Set<NodeId>;
|
|
28
|
+
/** Left child cluster (if internal node) */
|
|
29
|
+
left?: ClusterNode;
|
|
30
|
+
/** Right child cluster (if internal node) */
|
|
31
|
+
right?: ClusterNode;
|
|
32
|
+
/** Distance at which this cluster was formed */
|
|
33
|
+
distance: number;
|
|
34
|
+
/** Size of the cluster */
|
|
35
|
+
size: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of the TeraHAC clustering algorithm
|
|
40
|
+
*/
|
|
41
|
+
export interface TeraHACResult {
|
|
42
|
+
/** Root of the dendrogram */
|
|
43
|
+
dendrogram: ClusterNode;
|
|
44
|
+
/** Flat clustering at specified level */
|
|
45
|
+
clusters: Map<NodeId, number>;
|
|
46
|
+
/** All merge distances in order */
|
|
47
|
+
distances: number[];
|
|
48
|
+
/** Number of clusters in final result */
|
|
49
|
+
numClusters: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* TeraHAC - Hierarchical Agglomerative Clustering for Large Graphs
|
|
54
|
+
*
|
|
55
|
+
* This algorithm performs hierarchical clustering on graphs by iteratively
|
|
56
|
+
* merging the closest clusters. Optimized for scalability to handle large
|
|
57
|
+
* graphs efficiently.
|
|
58
|
+
*
|
|
59
|
+
* Based on: "Scaling Hierarchical Agglomerative Clustering to Trillion-Edge Graphs"
|
|
60
|
+
* Google Research 2024
|
|
61
|
+
*
|
|
62
|
+
* @param graph - Input graph to cluster
|
|
63
|
+
* @param config - Configuration options
|
|
64
|
+
* @returns Hierarchical clustering result
|
|
65
|
+
*/
|
|
66
|
+
export function teraHAC(graph: Graph, config: TeraHACConfig = {}): TeraHACResult {
|
|
67
|
+
const {
|
|
68
|
+
linkage = "average",
|
|
69
|
+
numClusters,
|
|
70
|
+
distanceThreshold,
|
|
71
|
+
maxNodes = 10000,
|
|
72
|
+
useGraphDistance = true,
|
|
73
|
+
} = config;
|
|
74
|
+
|
|
75
|
+
const nodes = Array.from(graph.nodes());
|
|
76
|
+
const nodeCount = nodes.length;
|
|
77
|
+
|
|
78
|
+
if (nodeCount === 0) {
|
|
79
|
+
throw new Error("Cannot cluster empty graph");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (nodeCount > maxNodes) {
|
|
83
|
+
console.warn(`Graph has ${String(nodeCount)} nodes, which exceeds maxNodes (${String(maxNodes)}). Performance may be degraded.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Initialize each node as its own cluster
|
|
87
|
+
const clusters = new Map<string, ClusterNode>();
|
|
88
|
+
let nextClusterId = nodeCount;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
91
|
+
const node = nodes[i];
|
|
92
|
+
if (!node) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const clusterId = i.toString();
|
|
97
|
+
clusters.set(clusterId, {
|
|
98
|
+
id: clusterId,
|
|
99
|
+
members: new Set([node.id]),
|
|
100
|
+
distance: 0,
|
|
101
|
+
size: 1,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Calculate initial distance matrix
|
|
106
|
+
const distanceMatrix = calculateDistanceMatrix(graph, nodes, useGraphDistance);
|
|
107
|
+
const mergeDistances: number[] = [];
|
|
108
|
+
|
|
109
|
+
// Priority queue for efficient nearest neighbor finding
|
|
110
|
+
const mergeCandidates = initializeMergeCandidates(clusters, distanceMatrix);
|
|
111
|
+
|
|
112
|
+
let dendrogram: ClusterNode | undefined;
|
|
113
|
+
|
|
114
|
+
// Perform agglomerative clustering
|
|
115
|
+
while (clusters.size > 1) {
|
|
116
|
+
// Find closest pair of clusters
|
|
117
|
+
const {cluster1Id, cluster2Id, distance} = findClosestPair(mergeCandidates);
|
|
118
|
+
|
|
119
|
+
// Check stopping criteria
|
|
120
|
+
if (numClusters && clusters.size <= numClusters) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (distanceThreshold && distance > distanceThreshold) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Get the two clusters to merge
|
|
129
|
+
const cluster1 = clusters.get(cluster1Id);
|
|
130
|
+
const cluster2 = clusters.get(cluster2Id);
|
|
131
|
+
if (!cluster1 || !cluster2) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Create new merged cluster
|
|
136
|
+
const newClusterId = (nextClusterId++).toString();
|
|
137
|
+
const mergedMembers = new Set([... cluster1.members, ... cluster2.members]);
|
|
138
|
+
|
|
139
|
+
const newCluster: ClusterNode = {
|
|
140
|
+
id: newClusterId,
|
|
141
|
+
members: mergedMembers,
|
|
142
|
+
left: cluster1,
|
|
143
|
+
right: cluster2,
|
|
144
|
+
distance,
|
|
145
|
+
size: cluster1.size + cluster2.size,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Remove old clusters
|
|
149
|
+
clusters.delete(cluster1Id);
|
|
150
|
+
clusters.delete(cluster2Id);
|
|
151
|
+
|
|
152
|
+
// Add new cluster
|
|
153
|
+
clusters.set(newClusterId, newCluster);
|
|
154
|
+
mergeDistances.push(distance);
|
|
155
|
+
|
|
156
|
+
// Update merge candidates
|
|
157
|
+
updateMergeCandidates(mergeCandidates, cluster1Id, cluster2Id, newClusterId, clusters, distanceMatrix, linkage);
|
|
158
|
+
|
|
159
|
+
dendrogram = newCluster;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// If we have multiple remaining clusters, create a virtual root
|
|
163
|
+
if (clusters.size > 1) {
|
|
164
|
+
const remainingClusters = Array.from(clusters.values());
|
|
165
|
+
let root = remainingClusters[0];
|
|
166
|
+
if (!root) {
|
|
167
|
+
dendrogram = undefined;
|
|
168
|
+
} else {
|
|
169
|
+
for (let i = 1; i < remainingClusters.length; i++) {
|
|
170
|
+
const currentCluster = remainingClusters[i];
|
|
171
|
+
if (!currentCluster) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const newRoot: ClusterNode = {
|
|
176
|
+
id: (nextClusterId++).toString(),
|
|
177
|
+
members: new Set([... root.members, ... currentCluster.members]),
|
|
178
|
+
left: root,
|
|
179
|
+
right: currentCluster,
|
|
180
|
+
distance: Infinity,
|
|
181
|
+
size: root.size + currentCluster.size,
|
|
182
|
+
};
|
|
183
|
+
root = newRoot;
|
|
184
|
+
}
|
|
185
|
+
dendrogram = root;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
dendrogram = root;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dendrogram ??= Array.from(clusters.values())[0];
|
|
192
|
+
|
|
193
|
+
// Extract flat clustering
|
|
194
|
+
const finalNumClusters = numClusters ?? clusters.size;
|
|
195
|
+
const flatClusters = dendrogram ? extractFlatClustering(dendrogram, finalNumClusters) : new Map<NodeId, number>();
|
|
196
|
+
|
|
197
|
+
if (!dendrogram) {
|
|
198
|
+
throw new Error("Failed to create dendrogram");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
dendrogram,
|
|
203
|
+
clusters: flatClusters,
|
|
204
|
+
distances: mergeDistances,
|
|
205
|
+
numClusters: finalNumClusters,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Calculate distance matrix between all pairs of nodes
|
|
211
|
+
*/
|
|
212
|
+
function calculateDistanceMatrix(graph: Graph, nodes: {id: NodeId}[], useGraphDistance: boolean): number[][] {
|
|
213
|
+
const n = nodes.length;
|
|
214
|
+
const matrix: number[][] = Array.from({length: n}, () => new Array(n).fill(Infinity) as number[]);
|
|
215
|
+
|
|
216
|
+
if (useGraphDistance) {
|
|
217
|
+
// Use graph-based distances (shortest path)
|
|
218
|
+
for (let i = 0; i < n; i++) {
|
|
219
|
+
const node = nodes[i];
|
|
220
|
+
if (!node) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const distances = bfsShortestPaths(graph, node.id);
|
|
225
|
+
for (let j = 0; j < n; j++) {
|
|
226
|
+
if (i !== j) {
|
|
227
|
+
const targetNode = nodes[j];
|
|
228
|
+
if (targetNode && i < matrix.length && j < n) {
|
|
229
|
+
const row = matrix[i];
|
|
230
|
+
if (row && j < row.length) {
|
|
231
|
+
const distance = distances.get(targetNode.id);
|
|
232
|
+
row[j] = distance ?? Infinity;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} else if (i < matrix.length) {
|
|
236
|
+
const row = matrix[i];
|
|
237
|
+
if (row && j < row.length) {
|
|
238
|
+
row[j] = 0;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
// Use simple edge-based distances
|
|
245
|
+
for (let i = 0; i < n; i++) {
|
|
246
|
+
for (let j = i + 1; j < n; j++) {
|
|
247
|
+
const node1 = nodes[i];
|
|
248
|
+
const node2 = nodes[j];
|
|
249
|
+
if (node1 && node2) {
|
|
250
|
+
const hasEdge = graph.hasEdge(node1.id, node2.id);
|
|
251
|
+
const distance = hasEdge ? 1 : 2; // Connected: 1, not connected: 2
|
|
252
|
+
if (i < matrix.length) {
|
|
253
|
+
const rowI = matrix[i];
|
|
254
|
+
if (rowI && j < rowI.length) {
|
|
255
|
+
rowI[j] = distance;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (j < matrix.length) {
|
|
260
|
+
const rowJ = matrix[j];
|
|
261
|
+
if (rowJ && i < rowJ.length) {
|
|
262
|
+
rowJ[i] = distance;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (i < matrix.length) {
|
|
268
|
+
const row = matrix[i];
|
|
269
|
+
if (row && i < row.length) {
|
|
270
|
+
row[i] = 0;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return matrix;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* BFS-based shortest path calculation from a source node
|
|
281
|
+
*/
|
|
282
|
+
function bfsShortestPaths(graph: Graph, source: NodeId): Map<NodeId, number> {
|
|
283
|
+
const distances = new Map<NodeId, number>();
|
|
284
|
+
const queue: [NodeId, number][] = [[source, 0]];
|
|
285
|
+
const visited = new Set<NodeId>();
|
|
286
|
+
|
|
287
|
+
visited.add(source);
|
|
288
|
+
distances.set(source, 0);
|
|
289
|
+
|
|
290
|
+
while (queue.length > 0) {
|
|
291
|
+
const item = queue.shift();
|
|
292
|
+
if (!item) {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const [current, distance] = item;
|
|
297
|
+
|
|
298
|
+
for (const neighbor of graph.neighbors(current)) {
|
|
299
|
+
if (!visited.has(neighbor)) {
|
|
300
|
+
visited.add(neighbor);
|
|
301
|
+
distances.set(neighbor, distance + 1);
|
|
302
|
+
queue.push([neighbor, distance + 1]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return distances;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Initialize merge candidates priority queue
|
|
312
|
+
*/
|
|
313
|
+
function initializeMergeCandidates(
|
|
314
|
+
clusters: Map<string, ClusterNode>,
|
|
315
|
+
distanceMatrix: number[][],
|
|
316
|
+
): {cluster1Id: string, cluster2Id: string, distance: number}[] {
|
|
317
|
+
const candidates: {cluster1Id: string, cluster2Id: string, distance: number}[] = [];
|
|
318
|
+
const clusterIds = Array.from(clusters.keys());
|
|
319
|
+
|
|
320
|
+
for (let i = 0; i < clusterIds.length; i++) {
|
|
321
|
+
for (let j = i + 1; j < clusterIds.length; j++) {
|
|
322
|
+
const id1 = clusterIds[i];
|
|
323
|
+
const id2 = clusterIds[j];
|
|
324
|
+
if (!id1 || !id2) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const row = distanceMatrix[parseInt(id1)];
|
|
329
|
+
if (!row) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const distance = row[parseInt(id2)] ?? Infinity;
|
|
334
|
+
// Include all candidates, even disconnected ones (with finite but large distance)
|
|
335
|
+
candidates.push({
|
|
336
|
+
cluster1Id: id1,
|
|
337
|
+
cluster2Id: id2,
|
|
338
|
+
distance: distance === Infinity ? 100 : distance,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Sort by distance (ascending)
|
|
344
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
345
|
+
return candidates;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Find the closest pair of clusters
|
|
350
|
+
*/
|
|
351
|
+
function findClosestPair(
|
|
352
|
+
mergeCandidates: {cluster1Id: string, cluster2Id: string, distance: number}[],
|
|
353
|
+
): {cluster1Id: string, cluster2Id: string, distance: number} {
|
|
354
|
+
// Return the first (closest) valid candidate
|
|
355
|
+
const candidate = mergeCandidates.shift();
|
|
356
|
+
if (!candidate) {
|
|
357
|
+
throw new Error("No merge candidates available");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return candidate;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Update merge candidates after a merge operation
|
|
365
|
+
*/
|
|
366
|
+
function updateMergeCandidates(
|
|
367
|
+
mergeCandidates: {cluster1Id: string, cluster2Id: string, distance: number}[],
|
|
368
|
+
oldCluster1Id: string,
|
|
369
|
+
oldCluster2Id: string,
|
|
370
|
+
newClusterId: string,
|
|
371
|
+
clusters: Map<string, ClusterNode>,
|
|
372
|
+
distanceMatrix: number[][],
|
|
373
|
+
linkage: string,
|
|
374
|
+
): void {
|
|
375
|
+
// Remove candidates involving the merged clusters
|
|
376
|
+
for (let i = mergeCandidates.length - 1; i >= 0; i--) {
|
|
377
|
+
const candidate = mergeCandidates[i];
|
|
378
|
+
if (!candidate) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (candidate.cluster1Id === oldCluster1Id || candidate.cluster1Id === oldCluster2Id ||
|
|
383
|
+
candidate.cluster2Id === oldCluster1Id || candidate.cluster2Id === oldCluster2Id) {
|
|
384
|
+
mergeCandidates.splice(i, 1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Add new candidates for the merged cluster
|
|
389
|
+
const newCluster = clusters.get(newClusterId);
|
|
390
|
+
if (!newCluster) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const [clusterId, cluster] of clusters) {
|
|
395
|
+
if (clusterId !== newClusterId) {
|
|
396
|
+
const distance = calculateClusterDistance(newCluster, cluster, distanceMatrix, linkage);
|
|
397
|
+
mergeCandidates.push({
|
|
398
|
+
cluster1Id: newClusterId,
|
|
399
|
+
cluster2Id: clusterId,
|
|
400
|
+
distance: distance === Infinity ? 100 : distance,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Re-sort candidates
|
|
406
|
+
mergeCandidates.sort((a, b) => a.distance - b.distance);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Calculate distance between two clusters based on linkage criterion
|
|
411
|
+
*/
|
|
412
|
+
function calculateClusterDistance(
|
|
413
|
+
cluster1: ClusterNode,
|
|
414
|
+
cluster2: ClusterNode,
|
|
415
|
+
distanceMatrix: number[][],
|
|
416
|
+
linkage: string,
|
|
417
|
+
): number {
|
|
418
|
+
const members1 = Array.from(cluster1.members);
|
|
419
|
+
const members2 = Array.from(cluster2.members);
|
|
420
|
+
const distances: number[] = [];
|
|
421
|
+
|
|
422
|
+
// Calculate all pairwise distances between cluster members
|
|
423
|
+
for (const member1 of members1) {
|
|
424
|
+
for (const member2 of members2) {
|
|
425
|
+
const idx1 = parseInt(member1.toString());
|
|
426
|
+
const idx2 = parseInt(member2.toString());
|
|
427
|
+
if (idx1 < distanceMatrix.length && idx2 < distanceMatrix.length) {
|
|
428
|
+
const row = distanceMatrix[idx1];
|
|
429
|
+
if (row) {
|
|
430
|
+
const distance = row[idx2];
|
|
431
|
+
if (distance !== undefined) {
|
|
432
|
+
distances.push(distance);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (distances.length === 0) {
|
|
440
|
+
return Infinity;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Apply linkage criterion
|
|
444
|
+
switch (linkage) {
|
|
445
|
+
case "single":
|
|
446
|
+
return Math.min(... distances);
|
|
447
|
+
case "complete":
|
|
448
|
+
return Math.max(... distances);
|
|
449
|
+
case "average":
|
|
450
|
+
return distances.reduce((sum, d) => sum + d, 0) / distances.length;
|
|
451
|
+
case "ward":
|
|
452
|
+
// Simplified Ward linkage (would need cluster centroids for full implementation)
|
|
453
|
+
return distances.reduce((sum, d) => sum + (d * d), 0) / distances.length;
|
|
454
|
+
default:
|
|
455
|
+
return distances.reduce((sum, d) => sum + d, 0) / distances.length;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Extract flat clustering from dendrogram
|
|
461
|
+
*/
|
|
462
|
+
function extractFlatClustering(dendrogram: ClusterNode, numClusters: number): Map<NodeId, number> {
|
|
463
|
+
const clusters = new Map<NodeId, number>();
|
|
464
|
+
|
|
465
|
+
if (numClusters === 1) {
|
|
466
|
+
// Single cluster
|
|
467
|
+
const clusterId = 0;
|
|
468
|
+
for (const member of dendrogram.members) {
|
|
469
|
+
clusters.set(member, clusterId);
|
|
470
|
+
}
|
|
471
|
+
return clusters;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Find clusters at the specified level
|
|
475
|
+
const clusterNodes: ClusterNode[] = [];
|
|
476
|
+
const queue: ClusterNode[] = [dendrogram];
|
|
477
|
+
|
|
478
|
+
while (queue.length > 0 && clusterNodes.length < numClusters) {
|
|
479
|
+
const current = queue.shift();
|
|
480
|
+
if (!current) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!current.left || !current.right || clusterNodes.length + queue.length + 1 >= numClusters) {
|
|
485
|
+
// This is a leaf or we need to keep this level
|
|
486
|
+
clusterNodes.push(current);
|
|
487
|
+
} else {
|
|
488
|
+
// Continue decomposing
|
|
489
|
+
queue.push(current.left, current.right);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Assign cluster IDs
|
|
494
|
+
for (let i = 0; i < clusterNodes.length; i++) {
|
|
495
|
+
const cluster = clusterNodes[i];
|
|
496
|
+
if (!cluster) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
for (const member of cluster.members) {
|
|
501
|
+
clusters.set(member, i);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return clusters;
|
|
506
|
+
}
|