@graphty/algorithms 1.8.1 → 2.0.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 +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/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/algorithms",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Graph algorithms library for browser environments implemented in TypeScript",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"main": "dist/algorithms.js",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"coverage:shard:default": "COVERAGE_DIR=.coverage-parts/default vitest run --project=default --coverage",
|
|
108
108
|
"coverage:shard:browser": "COVERAGE_DIR=.coverage-parts/browser vitest run --project=browser --coverage",
|
|
109
109
|
"coverage:fast": "vitest run --project=default --coverage",
|
|
110
|
-
"coverage:preview": "npx serve coverage -p
|
|
110
|
+
"coverage:preview": "npx serve coverage -p ${PORT:?start it through servherd, which sets PORT}",
|
|
111
111
|
"test:browser": "vitest --project=browser",
|
|
112
112
|
"test:all": "vitest run --project=default --project=browser",
|
|
113
113
|
"test:performance": "tsx test/helpers/run-performance-regression.ts",
|
|
@@ -130,18 +130,18 @@
|
|
|
130
130
|
"dev": "tsc --watch",
|
|
131
131
|
"examples": "node examples/run-all-examples.js",
|
|
132
132
|
"examples:run": "node examples/run-all-examples.js",
|
|
133
|
-
"examples:html": "npm run build:bundle && vite",
|
|
134
|
-
"serve": "npm run build:bundle && vite",
|
|
133
|
+
"examples:html": "npm run build:bundle && vite --port ${PORT:?start it through servherd, which sets PORT} --strictPort",
|
|
134
|
+
"serve": "npm run build:bundle && vite --port ${PORT:?start it through servherd, which sets PORT} --strictPort",
|
|
135
135
|
"commit": "cz",
|
|
136
136
|
"ready:commit": "npm run build && npm run lint && npm run test:all",
|
|
137
137
|
"watch": "tsc --watch",
|
|
138
138
|
"docs:api": "typedoc && node scripts/sanitize-api-docs.js",
|
|
139
139
|
"docs:api:watch": "typedoc --watch",
|
|
140
|
-
"docs:dev": "vitepress dev docs",
|
|
140
|
+
"docs:dev": "vitepress dev docs --port ${PORT:?start it through servherd, which sets PORT} --strictPort",
|
|
141
141
|
"docs:watch": "npm run docs:api && (npm run docs:api:watch & npm run docs:dev & wait)",
|
|
142
142
|
"docs:build": "npm run docs:api && vitepress build docs",
|
|
143
|
-
"docs:preview": "vitepress preview docs",
|
|
144
|
-
"storybook": "
|
|
143
|
+
"docs:preview": "vitepress preview docs --port ${PORT:?start it through servherd, which sets PORT}",
|
|
144
|
+
"storybook": "storybook dev -p ${PORT:?start it through servherd, which sets PORT} --host ${HOST:-localhost} ${HTTPS_CERT_PATH:+--https --ssl-cert $HTTPS_CERT_PATH --ssl-key $HTTPS_KEY_PATH} --no-open",
|
|
145
145
|
"build-storybook": "storybook build"
|
|
146
146
|
}
|
|
147
147
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Graph } from "../../core/graph.js";
|
|
2
|
+
import { ConvergenceError } from "../../errors.js";
|
|
2
3
|
import type { CentralityOptions, CentralityResult } from "../../types/index.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -9,137 +10,155 @@ import type { CentralityOptions, CentralityResult } from "../../types/index.js";
|
|
|
9
10
|
* that themselves have high eigenvector centrality.
|
|
10
11
|
*
|
|
11
12
|
* Time complexity: O(V + E) per iteration
|
|
12
|
-
* Space complexity: O(V)
|
|
13
|
+
* Space complexity: O(V + E)
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
export interface EigenvectorCentralityOptions extends CentralityOptions {
|
|
16
17
|
maxIterations?: number; // Maximum iterations (default: 100)
|
|
17
|
-
tolerance?: number; // Convergence tolerance (default: 1e-6)
|
|
18
|
+
tolerance?: number; // Convergence tolerance per node (default: 1e-6)
|
|
18
19
|
startVector?: Map<string, number>; // Initial vector (optional)
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Calculate eigenvector centrality for all nodes in the graph.
|
|
23
|
-
*
|
|
24
|
+
*
|
|
25
|
+
* Iterates x <- (A + I) x, as networkx does. Shifting by the identity raises every eigenvalue by
|
|
26
|
+
* one and leaves the eigenvectors alone, so the largest eigenvalue becomes the only one of largest
|
|
27
|
+
* magnitude -- even on a bipartite graph, whose spectrum holds both +lambda and -lambda and makes
|
|
28
|
+
* unshifted power iteration oscillate forever. The run stops when the L1 change summed over all
|
|
29
|
+
* nodes falls below `n * tolerance`, networkx's test.
|
|
30
|
+
*
|
|
31
|
+
* A node is fed by `graph.neighbors`, i.e. by its out-neighbours on a directed graph. When the
|
|
32
|
+
* graph has no cycle along that relation (no edges, or a directed acyclic graph) the adjacency
|
|
33
|
+
* matrix is nilpotent, its only eigenvalue is 0, and every score is exactly 0.
|
|
24
34
|
* @param graph - The graph to compute eigenvector centrality on
|
|
25
35
|
* @param options - Configuration options for the computation
|
|
26
36
|
* @returns Object mapping node IDs to their eigenvector centrality scores
|
|
37
|
+
* @throws {ConvergenceError} When `maxIterations` passes do not meet `tolerance`, as networkx
|
|
38
|
+
* raises `PowerIterationFailedConvergence`. Raise `maxIterations` or `tolerance` and call again.
|
|
27
39
|
*/
|
|
28
40
|
export function eigenvectorCentrality(graph: Graph, options: EigenvectorCentralityOptions = {}): CentralityResult {
|
|
29
41
|
const { maxIterations = 100, tolerance = 1e-6, normalized = true, startVector } = options;
|
|
30
42
|
|
|
43
|
+
const nodeIds = Array.from(graph.nodes(), (node) => node.id);
|
|
44
|
+
const keys = nodeIds.map((id) => id.toString());
|
|
45
|
+
const n = keys.length;
|
|
31
46
|
const centrality: CentralityResult = {};
|
|
32
|
-
const nodes = Array.from(graph.nodes());
|
|
33
|
-
const nodeIds = nodes.map((node) => node.id);
|
|
34
47
|
|
|
35
|
-
if (
|
|
48
|
+
if (n === 0) {
|
|
36
49
|
return centrality;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let previousVector = new Map<string, number>();
|
|
52
|
+
const index = new Map(keys.map((key, i) => [key, i]));
|
|
53
|
+
const adjacency = nodeIds.map((id) => Array.from(graph.neighbors(id), (m) => index.get(m.toString()) ?? 0));
|
|
42
54
|
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const key = nodeId.toString();
|
|
47
|
-
currentVector.set(key, startVector.get(key) ?? 1.0 / Math.sqrt(nodeIds.length));
|
|
48
|
-
}
|
|
49
|
-
} else {
|
|
50
|
-
// Initialize with uniform distribution
|
|
51
|
-
const initialValue = 1.0 / Math.sqrt(nodeIds.length);
|
|
52
|
-
for (const nodeId of nodeIds) {
|
|
53
|
-
currentVector.set(nodeId.toString(), initialValue);
|
|
55
|
+
if (isNilpotent(adjacency)) {
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
centrality[key] = 0;
|
|
54
58
|
}
|
|
59
|
+
return centrality;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
currentVector.set(nodeId.toString(), sum);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Normalize the vector
|
|
77
|
-
let norm = 0;
|
|
78
|
-
for (const value of Array.from(currentVector.values())) {
|
|
79
|
-
norm += value * value;
|
|
80
|
-
}
|
|
81
|
-
norm = Math.sqrt(norm);
|
|
82
|
-
|
|
83
|
-
if (norm === 0) {
|
|
84
|
-
// Graph has no edges or is disconnected
|
|
85
|
-
for (const nodeId of nodeIds) {
|
|
86
|
-
centrality[nodeId.toString()] = 0;
|
|
62
|
+
// A node with nothing feeding it scores exactly 0 once the largest eigenvalue is positive,
|
|
63
|
+
// so it starts there and (A + I) keeps it there.
|
|
64
|
+
let x = new Float64Array(n);
|
|
65
|
+
for (let i = 0; i < n; i++) {
|
|
66
|
+
x[i] = adjacency[i]?.length ? (startVector?.get(keys[i] ?? "") ?? 1) : 0;
|
|
67
|
+
}
|
|
68
|
+
scaleToUnitLength(x);
|
|
69
|
+
|
|
70
|
+
let next = new Float64Array(n);
|
|
71
|
+
let converged = false;
|
|
72
|
+
for (let iteration = 0; iteration < maxIterations && !converged; iteration++) {
|
|
73
|
+
for (let i = 0; i < n; i++) {
|
|
74
|
+
let sum = x[i] ?? 0;
|
|
75
|
+
for (const j of adjacency[i] ?? []) {
|
|
76
|
+
sum += x[j] ?? 0;
|
|
87
77
|
}
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Normalize the vector
|
|
92
|
-
for (const [nodeId, value] of Array.from(currentVector)) {
|
|
93
|
-
currentVector.set(nodeId, value / norm);
|
|
78
|
+
next[i] = sum;
|
|
94
79
|
}
|
|
80
|
+
scaleToUnitLength(next);
|
|
95
81
|
|
|
96
|
-
|
|
97
|
-
let
|
|
98
|
-
|
|
99
|
-
const prevValue = previousVector.get(nodeId) ?? 0;
|
|
100
|
-
const diff = Math.abs(value - prevValue);
|
|
101
|
-
maxDiff = Math.max(maxDiff, diff);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (maxDiff < tolerance) {
|
|
105
|
-
break;
|
|
82
|
+
let change = 0;
|
|
83
|
+
for (let i = 0; i < n; i++) {
|
|
84
|
+
change += Math.abs((next[i] ?? 0) - (x[i] ?? 0));
|
|
106
85
|
}
|
|
86
|
+
[x, next] = [next, x];
|
|
87
|
+
converged = change < n * tolerance;
|
|
88
|
+
}
|
|
89
|
+
if (!converged) {
|
|
90
|
+
throw new ConvergenceError("eigenvectorCentrality", maxIterations, tolerance);
|
|
107
91
|
}
|
|
108
92
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
centrality[nodeId] = value;
|
|
93
|
+
for (let i = 0; i < n; i++) {
|
|
94
|
+
centrality[keys[i] ?? ""] = x[i] ?? 0;
|
|
112
95
|
}
|
|
113
96
|
|
|
114
97
|
// Additional normalization if requested (normalize to [0,1] range)
|
|
115
98
|
if (normalized) {
|
|
116
99
|
let maxValue = 0;
|
|
117
100
|
let minValue = Number.POSITIVE_INFINITY;
|
|
118
|
-
|
|
119
|
-
for (const value of Object.values(centrality)) {
|
|
101
|
+
for (const value of x) {
|
|
120
102
|
maxValue = Math.max(maxValue, value);
|
|
121
103
|
minValue = Math.min(minValue, value);
|
|
122
104
|
}
|
|
123
|
-
|
|
124
105
|
const range = maxValue - minValue;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
centrality[nodeId] = (centralityValue - minValue) / range;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
// All values are the same, set to 1
|
|
134
|
-
for (const nodeId of Object.keys(centrality)) {
|
|
135
|
-
centrality[nodeId] = maxValue > 0 ? 1 : 0;
|
|
136
|
-
}
|
|
106
|
+
// All scores equal: 1 each, or 0 each when there is nothing to score.
|
|
107
|
+
const flat = maxValue > 0 ? 1 : 0;
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
centrality[key] = range > 0 ? ((centrality[key] ?? 0) - minValue) / range : flat;
|
|
137
110
|
}
|
|
138
111
|
}
|
|
139
112
|
|
|
140
113
|
return centrality;
|
|
141
114
|
}
|
|
142
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Whether the relation has no cycle (Kahn's algorithm), which makes its adjacency matrix nilpotent.
|
|
118
|
+
* @param adjacency - Each node's neighbour indices
|
|
119
|
+
* @returns True when every node can be peeled off in topological order
|
|
120
|
+
*/
|
|
121
|
+
function isNilpotent(adjacency: number[][]): boolean {
|
|
122
|
+
const inDegree = new Int32Array(adjacency.length);
|
|
123
|
+
for (const targets of adjacency) {
|
|
124
|
+
for (const j of targets) {
|
|
125
|
+
inDegree[j] = (inDegree[j] ?? 0) + 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const queue: number[] = [];
|
|
129
|
+
inDegree.forEach((degree, i) => {
|
|
130
|
+
if (degree === 0) {
|
|
131
|
+
queue.push(i);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
for (let head = 0; head < queue.length; head++) {
|
|
135
|
+
for (const j of adjacency[queue[head] ?? 0] ?? []) {
|
|
136
|
+
inDegree[j] = (inDegree[j] ?? 0) - 1;
|
|
137
|
+
if (inDegree[j] === 0) {
|
|
138
|
+
queue.push(j);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return queue.length === adjacency.length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scale a vector in place to unit Euclidean length (a zero vector is left alone).
|
|
147
|
+
* @param v - The vector to scale
|
|
148
|
+
*/
|
|
149
|
+
function scaleToUnitLength(v: Float64Array): void {
|
|
150
|
+
let norm = 0;
|
|
151
|
+
for (const value of v) {
|
|
152
|
+
norm += value * value;
|
|
153
|
+
}
|
|
154
|
+
norm = Math.sqrt(norm);
|
|
155
|
+
if (norm > 0) {
|
|
156
|
+
for (let i = 0; i < v.length; i++) {
|
|
157
|
+
v[i] = (v[i] ?? 0) / norm;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
/**
|
|
144
163
|
* Calculate eigenvector centrality for a specific node.
|
|
145
164
|
* @param graph - The graph to compute eigenvector centrality on
|
|
@@ -297,7 +297,23 @@ function getEdgeKey(source: NodeId, target: NodeId): string {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/**
|
|
300
|
-
* Calculate modularity for a given community structure
|
|
300
|
+
* Calculate modularity for a given community structure
|
|
301
|
+
*
|
|
302
|
+
* Newman's Q, written per community:
|
|
303
|
+
*
|
|
304
|
+
* Q = sum over communities c of [ w_in(c) / m - ( K_c / (2m) )^2 ]
|
|
305
|
+
*
|
|
306
|
+
* where m is the total edge weight with each undirected edge counted once, w_in(c) is the summed
|
|
307
|
+
* weight of the edges with both endpoints inside c, and K_c is the summed weighted degree of c's
|
|
308
|
+
* nodes.
|
|
309
|
+
*
|
|
310
|
+
* WHY NOT A SUM OVER EDGES. The textbook double sum runs over every ordered PAIR of nodes inside
|
|
311
|
+
* a community, not only over the pairs an edge happens to join. Collecting the null-model term
|
|
312
|
+
* only where an edge exists leaves the penalty far too small, and the shortfall grows with
|
|
313
|
+
* community size -- so the uncut whole graph, whose modularity is 0 by definition, outscores
|
|
314
|
+
* every real cut and a caller reading the dendrogram the standard way is handed one community
|
|
315
|
+
* containing everything. The per-community form above counts every pair exactly once and still
|
|
316
|
+
* costs one pass over the edges rather than a pass over every pair.
|
|
301
317
|
* @param graph - The original graph
|
|
302
318
|
* @param communityMap - Map from node IDs to community indices
|
|
303
319
|
* @returns The modularity score of the partition
|
|
@@ -308,32 +324,37 @@ function calculateModularity(graph: Graph, communityMap: Map<NodeId, number>): n
|
|
|
308
324
|
return 0;
|
|
309
325
|
}
|
|
310
326
|
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
// Pre-calculate all degrees
|
|
327
|
+
// Summed weighted degree of each community's nodes.
|
|
328
|
+
const degreeSum = new Map<number, number>();
|
|
315
329
|
for (const node of graph.nodes()) {
|
|
316
|
-
|
|
330
|
+
const community = communityMap.get(node.id);
|
|
331
|
+
if (community === undefined) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
degreeSum.set(community, (degreeSum.get(community) ?? 0) + getNodeDegree(graph, node.id));
|
|
317
336
|
}
|
|
318
337
|
|
|
319
|
-
//
|
|
338
|
+
// Summed weight of the edges that stay inside a community.
|
|
339
|
+
const internalWeight = new Map<number, number>();
|
|
320
340
|
for (const edge of graph.edges()) {
|
|
321
341
|
const communityI = communityMap.get(edge.source);
|
|
322
342
|
const communityJ = communityMap.get(edge.target);
|
|
323
343
|
|
|
324
|
-
if (communityI === communityJ) {
|
|
325
|
-
|
|
326
|
-
const degreeI = degrees.get(edge.source);
|
|
327
|
-
const degreeJ = degrees.get(edge.target);
|
|
328
|
-
if (degreeI === undefined || degreeJ === undefined) {
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
modularity += edgeWeight - (degreeI * degreeJ) / (2 * totalEdgeWeight);
|
|
344
|
+
if (communityI === undefined || communityI !== communityJ) {
|
|
345
|
+
continue;
|
|
333
346
|
}
|
|
347
|
+
|
|
348
|
+
internalWeight.set(communityI, (internalWeight.get(communityI) ?? 0) + (edge.weight ?? 1));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let modularity = 0;
|
|
352
|
+
for (const [community, degrees] of degreeSum) {
|
|
353
|
+
const share = degrees / (2 * totalEdgeWeight);
|
|
354
|
+
modularity += (internalWeight.get(community) ?? 0) / totalEdgeWeight - share * share;
|
|
334
355
|
}
|
|
335
356
|
|
|
336
|
-
return modularity
|
|
357
|
+
return modularity;
|
|
337
358
|
}
|
|
338
359
|
|
|
339
360
|
/**
|