w-cluster 1.0.19 → 1.0.21
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/.github/workflows/ci-test.yml +3 -3
- package/README.md +4 -6
- package/babel.config.js +4 -3
- package/dist/w-cluster.umd.js +2 -2
- package/dist/w-cluster.umd.js.map +1 -1
- package/dist/w-cluster.wk.umd.js +1 -1
- package/docs/DenseMatrix.html +202 -0
- package/docs/DistancePair.html +202 -0
- package/docs/LowerTriangle.html +202 -0
- package/docs/Rec.html +202 -0
- package/docs/Reco.html +202 -0
- package/docs/WCluster.mjs.html +8 -4
- package/docs/examples/ex-PCA.html +1 -1
- package/docs/examples/ex-cluster-webworker.html +1 -1
- package/docs/examples/ex-cluster.html +1 -1
- package/docs/global.html +6799 -164
- package/docs/index.html +2 -2
- package/docs/k-medoids_alternating.mjs.html +144 -0
- package/docs/k-medoids_arrayadapter.mjs.html +107 -0
- package/docs/k-medoids_dynmsc.mjs.html +255 -0
- package/docs/k-medoids_fastermsc.mjs.html +411 -0
- package/docs/k-medoids_fasterpam.mjs.html +305 -0
- package/docs/k-medoids_fastmsc.mjs.html +166 -0
- package/docs/k-medoids_fastpam1.mjs.html +132 -0
- package/docs/k-medoids_initialization.mjs.html +107 -0
- package/docs/k-medoids_pam.mjs.html +275 -0
- package/docs/k-medoids_pammedsil.mjs.html +307 -0
- package/docs/k-medoids_pamsil.mjs.html +225 -0
- package/docs/k-medoids_par_fasterpam.mjs.html +134 -0
- package/docs/k-medoids_par_silhouette.mjs.html +126 -0
- package/docs/k-medoids_silhouette.mjs.html +193 -0
- package/docs/k-medoids_util.mjs.html +135 -0
- package/g-PCA-nodeworker.mjs +1 -1
- package/g-PCA.mjs +1 -1
- package/g-cluster-kMeans-large.mjs +83 -0
- package/g-cluster-kMeans-nodeworker.mjs +181 -0
- package/g-cluster-kMeans.mjs +178 -0
- package/g-cluster-kMedoids-large-suggest.mjs +95 -0
- package/g-cluster-kMedoids-large.mjs +104 -0
- package/{g-cluster-nodeworker.mjs → g-cluster-kMedoids-nodeworker.mjs} +1 -2
- package/{g-cluster.mjs → g-cluster-kMedoids-simple.mjs} +1 -1
- package/package.json +7 -7
- package/script.txt +18 -0
- package/src/WCluster.mjs +6 -2
- package/src/WClusterCore.mjs +30 -21
- package/src/WClusterMat.mjs +98 -24
- package/src/WPCAMat.mjs +2 -2
- package/src/jaccardBitset.mjs +63 -0
- package/src/k-medoids/README.md +124 -0
- package/src/k-medoids/alternating.mjs +72 -0
- package/src/k-medoids/arrayadapter.mjs +35 -0
- package/src/k-medoids/dynmsc.mjs +183 -0
- package/src/k-medoids/fastermsc.mjs +339 -0
- package/src/k-medoids/fasterpam.mjs +233 -0
- package/src/k-medoids/fastmsc.mjs +94 -0
- package/src/k-medoids/fastpam1.mjs +60 -0
- package/src/k-medoids/index.mjs +37 -0
- package/src/k-medoids/initialization.mjs +35 -0
- package/src/k-medoids/package.json +8 -0
- package/src/k-medoids/pam.mjs +203 -0
- package/src/k-medoids/pammedsil.mjs +235 -0
- package/src/k-medoids/pamsil.mjs +153 -0
- package/src/k-medoids/par_fasterpam.mjs +62 -0
- package/src/k-medoids/par_silhouette.mjs +54 -0
- package/src/k-medoids/silhouette.mjs +121 -0
- package/src/k-medoids/test.mjs +357 -0
- package/src/k-medoids/util.mjs +63 -0
- package/test/jaccardBitset.test.mjs +141 -0
- package/test/kMeans.test.mjs +278 -0
- package/test/kMedoids-large-suggest.test.mjs +140 -0
- package/test/kMedoids-large.test.mjs +142 -0
- package/test/{cluster.test.mjs → kMedoids-simple.test.mjs} +5 -5
- package/toolg/gDocsExams.mjs +1 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// FasterPAM algorithm, ported 1:1 from src/fasterpam.rs
|
|
2
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
3
|
+
import { Rec, DistancePair, U32_MAX, find_min, choose_medoid_within_partition } from './util.mjs';
|
|
4
|
+
import { shuffle } from './initialization.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run the FasterPAM algorithm.
|
|
8
|
+
*
|
|
9
|
+
* If used multiple times, it is better to additionally shuffle the input data,
|
|
10
|
+
* to increase randomness of the solutions found and hence increase the chance
|
|
11
|
+
* of finding a better solution.
|
|
12
|
+
*
|
|
13
|
+
* @param mat - a pairwise distance matrix
|
|
14
|
+
* @param med - the list of medoids (mutated in place)
|
|
15
|
+
* @param maxiter - the maximum number of iterations allowed
|
|
16
|
+
* @returns { loss, assi, nIter, nSwaps }
|
|
17
|
+
*
|
|
18
|
+
* Panics (throws) when the dissimilarity matrix is not square, or k is 0 or larger than N.
|
|
19
|
+
*/
|
|
20
|
+
export function fasterpam(mat, med, maxiter) {
|
|
21
|
+
mat = arrayAdapter(mat);
|
|
22
|
+
const n = mat.len(), k = med.length;
|
|
23
|
+
if (k === 1) {
|
|
24
|
+
const assi = new Array(n).fill(0);
|
|
25
|
+
const [swapped, loss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
26
|
+
return { loss, assi, nIter: 1, nSwaps: swapped ? 1 : 0 };
|
|
27
|
+
}
|
|
28
|
+
let [loss, data] = initial_assignment(mat, med);
|
|
29
|
+
let removal_loss = new Array(k).fill(0);
|
|
30
|
+
update_removal_loss(data, removal_loss);
|
|
31
|
+
let lastswap = n, n_swaps = 0, iter = 0;
|
|
32
|
+
while (iter < maxiter) {
|
|
33
|
+
iter += 1;
|
|
34
|
+
const swaps_before = n_swaps, lastloss = loss;
|
|
35
|
+
for (let j = 0; j < n; j++) {
|
|
36
|
+
if (j === lastswap) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (j === med[data[j].near.i]) {
|
|
40
|
+
continue; // This already is a medoid
|
|
41
|
+
}
|
|
42
|
+
const [change, b] = find_best_swap(mat, removal_loss, data, j);
|
|
43
|
+
if (change >= 0) {
|
|
44
|
+
continue; // No improvement
|
|
45
|
+
}
|
|
46
|
+
n_swaps += 1;
|
|
47
|
+
lastswap = j;
|
|
48
|
+
// perform the swap
|
|
49
|
+
loss = do_swap(mat, med, data, b, j);
|
|
50
|
+
update_removal_loss(data, removal_loss);
|
|
51
|
+
}
|
|
52
|
+
if (n_swaps === swaps_before || loss >= lastloss) {
|
|
53
|
+
break; // converged
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const assi = data.map((x) => x.near.i);
|
|
57
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the FasterPAM algorithm with additional randomization.
|
|
62
|
+
*
|
|
63
|
+
* This increases the chance of finding a better solution when used multiple times,
|
|
64
|
+
* as it decreases the dependency on the input data order.
|
|
65
|
+
*
|
|
66
|
+
* @param mat - a pairwise distance matrix
|
|
67
|
+
* @param med - the list of medoids (mutated in place)
|
|
68
|
+
* @param maxiter - the maximum number of iterations allowed
|
|
69
|
+
* @param rng - random number generator for shuffling the input data
|
|
70
|
+
* @returns { loss, assi, nIter, nSwaps }
|
|
71
|
+
*/
|
|
72
|
+
export function rand_fasterpam(mat, med, maxiter, rng = Math.random) {
|
|
73
|
+
mat = arrayAdapter(mat);
|
|
74
|
+
const n = mat.len(), k = med.length;
|
|
75
|
+
if (k === 1) {
|
|
76
|
+
const assi = new Array(n).fill(0);
|
|
77
|
+
const [swapped, loss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
78
|
+
return { loss, assi, nIter: 1, nSwaps: swapped ? 1 : 0 };
|
|
79
|
+
}
|
|
80
|
+
let [loss, data] = initial_assignment(mat, med);
|
|
81
|
+
|
|
82
|
+
let removal_loss = new Array(k).fill(0);
|
|
83
|
+
update_removal_loss(data, removal_loss);
|
|
84
|
+
let lastswap = n, n_swaps = 0, iter = 0;
|
|
85
|
+
const seq = shuffle(rng, n); // random shuffling
|
|
86
|
+
while (iter < maxiter) {
|
|
87
|
+
iter += 1;
|
|
88
|
+
const swaps_before = n_swaps, lastloss = loss;
|
|
89
|
+
for (const j of seq) {
|
|
90
|
+
if (j === lastswap) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (j === med[data[j].near.i]) {
|
|
94
|
+
continue; // This already is a medoid
|
|
95
|
+
}
|
|
96
|
+
const [change, b] = find_best_swap(mat, removal_loss, data, j);
|
|
97
|
+
if (change >= 0) {
|
|
98
|
+
continue; // No improvement
|
|
99
|
+
}
|
|
100
|
+
n_swaps += 1;
|
|
101
|
+
lastswap = j;
|
|
102
|
+
// perform the swap
|
|
103
|
+
loss = do_swap(mat, med, data, b, j);
|
|
104
|
+
update_removal_loss(data, removal_loss);
|
|
105
|
+
}
|
|
106
|
+
if (n_swaps === swaps_before || loss >= lastloss) {
|
|
107
|
+
break; // converged
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const assi = data.map((x) => x.near.i);
|
|
111
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Perform the initial assignment to medoids. Returns [loss, data] with data = Rec[]. */
|
|
115
|
+
export function initial_assignment(mat, med) {
|
|
116
|
+
const n = mat.len(), k = med.length;
|
|
117
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
118
|
+
if (!(n <= U32_MAX)) throw new Error('N is too large');
|
|
119
|
+
if (!(k > 0 && k < U32_MAX)) throw new Error('invalid N');
|
|
120
|
+
if (!(k <= n)) throw new Error('k must be at most N');
|
|
121
|
+
const data = Array.from({ length: mat.len() }, () => Rec.empty());
|
|
122
|
+
|
|
123
|
+
const firstcenter = med[0];
|
|
124
|
+
let loss = 0;
|
|
125
|
+
for (let i = 0; i < data.length; i++) {
|
|
126
|
+
// *cur = Rec::new(...): overwrite the slot with a fresh Rec, then mutate it
|
|
127
|
+
data[i] = new Rec(0, mat.get(i, firstcenter), U32_MAX, 0);
|
|
128
|
+
const curRec = data[i];
|
|
129
|
+
for (let m = 1; m < med.length; m++) {
|
|
130
|
+
const me = med[m];
|
|
131
|
+
const d = mat.get(i, me);
|
|
132
|
+
if (d < curRec.near.d || i === me) {
|
|
133
|
+
curRec.seco = curRec.near.clone();
|
|
134
|
+
curRec.near = new DistancePair(m, d);
|
|
135
|
+
} else if (curRec.seco.i === U32_MAX || d < curRec.seco.d) {
|
|
136
|
+
curRec.seco = new DistancePair(m, d);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
loss += curRec.near.d;
|
|
140
|
+
}
|
|
141
|
+
return [loss, data];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Find the best swap for object j - FastPAM version. Returns [change, b]. */
|
|
145
|
+
export function find_best_swap(mat, removal_loss, data, j) {
|
|
146
|
+
const ploss = removal_loss.slice();
|
|
147
|
+
// Improvement from the journal version:
|
|
148
|
+
let acc = 0;
|
|
149
|
+
for (let o = 0; o < data.length; o++) {
|
|
150
|
+
const reco = data[o];
|
|
151
|
+
const doj = mat.get(o, j);
|
|
152
|
+
// New medoid is closest:
|
|
153
|
+
if (doj < reco.near.d) {
|
|
154
|
+
acc += doj - reco.near.d;
|
|
155
|
+
// loss already includes ds - dn, remove
|
|
156
|
+
ploss[reco.near.i] += reco.near.d - reco.seco.d;
|
|
157
|
+
} else if (doj < reco.seco.d) {
|
|
158
|
+
// loss already includes ds - dn, adjust to d(xo) - dn
|
|
159
|
+
ploss[reco.near.i] += doj - reco.seco.d;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const [b, bloss] = find_min(ploss);
|
|
163
|
+
return [bloss + acc, b]; // add the shared accumulator
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Update the loss when removing each medoid. Mutates lossArr. */
|
|
167
|
+
export function update_removal_loss(data, loss) {
|
|
168
|
+
loss.fill(0); // stable since 1.50
|
|
169
|
+
for (let r = 0; r < data.length; r++) {
|
|
170
|
+
const rec = data[r];
|
|
171
|
+
loss[rec.near.i] += rec.seco.d - rec.near.d;
|
|
172
|
+
// as N might be unsigned
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Update the second nearest medoid information. Called after each swap. Returns a fresh DistancePair. */
|
|
177
|
+
export function update_second_nearest(mat, med, n, b, o, doj) {
|
|
178
|
+
let s = new DistancePair(b, doj);
|
|
179
|
+
for (let i = 0; i < med.length; i++) {
|
|
180
|
+
const mi = med[i];
|
|
181
|
+
if (i === n || i === b) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const d = mat.get(o, mi);
|
|
185
|
+
if (d < s.d) {
|
|
186
|
+
s = new DistancePair(i, d);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return s;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Perform a single swap. Returns the RAW summed loss (sum of near.d). */
|
|
193
|
+
export function do_swap(mat, med, data, b, j) {
|
|
194
|
+
const n = mat.len();
|
|
195
|
+
if (!(b < med.length)) throw new Error('invalid medoid number');
|
|
196
|
+
if (!(j < n)) throw new Error('invalid object number');
|
|
197
|
+
med[b] = j;
|
|
198
|
+
let acc = 0;
|
|
199
|
+
for (let o = 0; o < data.length; o++) {
|
|
200
|
+
const reco = data[o];
|
|
201
|
+
if (o === j) {
|
|
202
|
+
if (reco.near.i !== b) {
|
|
203
|
+
reco.seco = reco.near.clone();
|
|
204
|
+
}
|
|
205
|
+
reco.near = new DistancePair(b, 0);
|
|
206
|
+
acc += 0;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const doj = mat.get(o, j);
|
|
210
|
+
// Nearest medoid is gone:
|
|
211
|
+
if (reco.near.i === b) {
|
|
212
|
+
if (doj < reco.seco.d) {
|
|
213
|
+
reco.near = new DistancePair(b, doj);
|
|
214
|
+
} else {
|
|
215
|
+
reco.near = reco.seco.clone();
|
|
216
|
+
reco.seco = update_second_nearest(mat, med, reco.near.i, b, o, doj);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// nearest not removed
|
|
220
|
+
if (doj < reco.near.d) {
|
|
221
|
+
reco.seco = reco.near.clone();
|
|
222
|
+
reco.near = new DistancePair(b, doj);
|
|
223
|
+
} else if (doj < reco.seco.d) {
|
|
224
|
+
reco.seco = new DistancePair(b, doj);
|
|
225
|
+
} else if (reco.seco.i === b) {
|
|
226
|
+
// second nearest was replaced
|
|
227
|
+
reco.seco = update_second_nearest(mat, med, reco.near.i, b, o, doj);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
acc += reco.near.d;
|
|
231
|
+
}
|
|
232
|
+
return acc;
|
|
233
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Ported 1:1 from src/fastmsc.rs (FastMSC algorithm).
|
|
2
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
3
|
+
import { USIZE_MAX, choose_medoid_within_partition } from './util.mjs';
|
|
4
|
+
import { initial_assignment, update_removal_loss, find_best_swap, do_swap, initial_assignment_k2, find_best_swap_k2, do_swap_k2 } from './fastermsc.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run the FastMSC algorithm, which yields the same results as the original PAMMEDSIL.
|
|
8
|
+
*
|
|
9
|
+
* This is faster than PAMMEDSIL, but slower than FasterMSC, and mostly of interest for academic reasons.
|
|
10
|
+
* This is the improved version, which costs O(n^2) per iteration to find the best swap.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} mat - a pairwise distance matrix
|
|
13
|
+
* @param {number[]} med - the list of medoids (mutated in place)
|
|
14
|
+
* @param {number} maxiter - the maximum number of iterations allowed
|
|
15
|
+
* returns { loss, assi, nIter, nSwaps }
|
|
16
|
+
*/
|
|
17
|
+
export function fastmsc(mat, med, maxiter) {
|
|
18
|
+
mat = arrayAdapter(mat);
|
|
19
|
+
const n = mat.len(), k = med.length;
|
|
20
|
+
if (k === 1) {
|
|
21
|
+
const assi = new Array(n).fill(0);
|
|
22
|
+
const [swapped, loss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
23
|
+
return { loss, assi, nIter: 1, nSwaps: swapped ? 1 : 0 };
|
|
24
|
+
}
|
|
25
|
+
if (k === 2) { // special hadling, as there is no third
|
|
26
|
+
return fastmsc_k2(mat, med, maxiter);
|
|
27
|
+
}
|
|
28
|
+
let [loss, data] = initial_assignment(mat, med);
|
|
29
|
+
|
|
30
|
+
let removal_loss = new Array(k).fill(0);
|
|
31
|
+
let n_swaps = 0, iter = 0;
|
|
32
|
+
while (iter < maxiter) {
|
|
33
|
+
iter += 1;
|
|
34
|
+
let best = [0, USIZE_MAX, USIZE_MAX];
|
|
35
|
+
update_removal_loss(data, removal_loss);
|
|
36
|
+
for (let j = 0; j < n; j++) {
|
|
37
|
+
if (j === med[data[j].near.i]) {
|
|
38
|
+
continue; // This already is a medoid
|
|
39
|
+
}
|
|
40
|
+
const [change, b] = find_best_swap(mat, removal_loss, data, j);
|
|
41
|
+
if (change <= best[0]) {
|
|
42
|
+
continue; // No improvement
|
|
43
|
+
}
|
|
44
|
+
best = [change, b, j];
|
|
45
|
+
}
|
|
46
|
+
if (best[0] > 0) {
|
|
47
|
+
n_swaps += 1;
|
|
48
|
+
// perform the swap
|
|
49
|
+
const newloss = do_swap(mat, med, data, best[1], best[2]);
|
|
50
|
+
if (newloss >= loss) {
|
|
51
|
+
break; // Probably numerically unstable now.
|
|
52
|
+
}
|
|
53
|
+
loss = newloss;
|
|
54
|
+
} else {
|
|
55
|
+
break; // No improvement, or NaN.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const assi = data.map((x) => x.near.i);
|
|
59
|
+
loss = 1 - loss / n;
|
|
60
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Special case k=2 of the FastMSC algorithm. */
|
|
64
|
+
function fastmsc_k2(mat, med, maxiter) {
|
|
65
|
+
const n = mat.len(), k = med.length;
|
|
66
|
+
if (!(k === 2)) throw new Error('Only valid for k=2');
|
|
67
|
+
let [loss, assi, data] = initial_assignment_k2(mat, med);
|
|
68
|
+
let n_swaps = 0, iter = 0;
|
|
69
|
+
while (iter < maxiter) {
|
|
70
|
+
iter += 1;
|
|
71
|
+
let best = [0, k, USIZE_MAX];
|
|
72
|
+
for (let j = 0; j < n; j++) {
|
|
73
|
+
if (j === med[assi[j]]) {
|
|
74
|
+
continue; // This already is a medoid
|
|
75
|
+
}
|
|
76
|
+
const [newloss, b] = find_best_swap_k2(mat, data, j); // assi not used, see below
|
|
77
|
+
if (best[2] === USIZE_MAX || newloss < best[0]) {
|
|
78
|
+
best = [newloss, b, j];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!(best[0] < loss)) {
|
|
82
|
+
break; // No improvement
|
|
83
|
+
}
|
|
84
|
+
// perform the swap
|
|
85
|
+
n_swaps += 1;
|
|
86
|
+
const newloss = do_swap_k2(mat, med, assi, data, best[1], best[2]);
|
|
87
|
+
if (!(newloss < loss)) {
|
|
88
|
+
break; // Probably numerically unstable
|
|
89
|
+
}
|
|
90
|
+
loss = newloss;
|
|
91
|
+
}
|
|
92
|
+
loss = 1 - loss / n;
|
|
93
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
94
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
2
|
+
import { USIZE_MAX, choose_medoid_within_partition } from './util.mjs';
|
|
3
|
+
import { initial_assignment, find_best_swap, do_swap, update_removal_loss } from './fasterpam.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Run the FastPAM1 algorithm, which yields the same results as the original PAM.
|
|
7
|
+
*
|
|
8
|
+
* This is faster than PAM, but slower than FasterPAM, and mostly of interest for academic reasons.
|
|
9
|
+
* Quality-wise, FasterPAM is not worse on average, but much faster.
|
|
10
|
+
*
|
|
11
|
+
* This is the improved version from the journal version of the paper,
|
|
12
|
+
* which costs O(n²) per iteration to find the best swap.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} mat - a pairwise distance matrix (array or wrapped)
|
|
15
|
+
* @param {number[]} med - the list of medoids (mutated in place)
|
|
16
|
+
* @param {number} maxiter - the maximum number of iterations allowed
|
|
17
|
+
* @returns {{ loss: number, assi: number[], nIter: number, nSwaps: number }}
|
|
18
|
+
*/
|
|
19
|
+
export function fastpam1(mat, med, maxiter) {
|
|
20
|
+
mat = arrayAdapter(mat);
|
|
21
|
+
const n = mat.len();
|
|
22
|
+
const k = med.length;
|
|
23
|
+
if (k === 1) {
|
|
24
|
+
const assi = new Array(n).fill(0);
|
|
25
|
+
const [swapped, loss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
26
|
+
return { loss, assi, nIter: 1, nSwaps: swapped ? 1 : 0 };
|
|
27
|
+
}
|
|
28
|
+
let [loss, data] = initial_assignment(mat, med);
|
|
29
|
+
const removal_loss = new Array(k).fill(0);
|
|
30
|
+
let n_swaps = 0;
|
|
31
|
+
let iter = 0;
|
|
32
|
+
while (iter < maxiter) {
|
|
33
|
+
iter += 1;
|
|
34
|
+
let best = [0, USIZE_MAX, USIZE_MAX];
|
|
35
|
+
update_removal_loss(data, removal_loss);
|
|
36
|
+
for (let j = 0; j < n; j++) {
|
|
37
|
+
if (j === med[data[j].near.i]) {
|
|
38
|
+
continue; // This already is a medoid
|
|
39
|
+
}
|
|
40
|
+
const [change, b] = find_best_swap(mat, removal_loss, data, j);
|
|
41
|
+
if (change >= best[0]) {
|
|
42
|
+
continue; // No improvement
|
|
43
|
+
}
|
|
44
|
+
best = [change, b, j];
|
|
45
|
+
}
|
|
46
|
+
if (best[0] < 0) {
|
|
47
|
+
n_swaps += 1;
|
|
48
|
+
// perform the swap
|
|
49
|
+
const newloss = do_swap(mat, med, data, best[1], best[2]);
|
|
50
|
+
if (newloss >= loss) {
|
|
51
|
+
break; // Probably numerically unstable now.
|
|
52
|
+
}
|
|
53
|
+
loss = newloss;
|
|
54
|
+
} else {
|
|
55
|
+
break; // No improvement, or NaN.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const assi = data.map(x => x.near.i);
|
|
59
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k-Medoids Clustering with the FasterPAM Algorithm
|
|
3
|
+
*
|
|
4
|
+
* For details on the implemented FasterPAM algorithm, please see:
|
|
5
|
+
*
|
|
6
|
+
* Erich Schubert, Peter J. Rousseeuw
|
|
7
|
+
* Fast and Eager k-Medoids Clustering:
|
|
8
|
+
* O(k) Runtime Improvement of the PAM, CLARA, and CLARANS Algorithms
|
|
9
|
+
* Information Systems (101), 2021, 101804
|
|
10
|
+
* https://doi.org/10.1016/j.is.2021.101804 (open access)
|
|
11
|
+
*
|
|
12
|
+
* Erich Schubert, Peter J. Rousseeuw:
|
|
13
|
+
* Faster k-Medoids Clustering: Improving the PAM, CLARA, and CLARANS Algorithms
|
|
14
|
+
* In: 12th International Conference on Similarity Search and Applications (SISAP 2019), 171-187.
|
|
15
|
+
* https://doi.org/10.1007/978-3-030-32047-8_16
|
|
16
|
+
* Preprint: https://arxiv.org/abs/1810.05691
|
|
17
|
+
*
|
|
18
|
+
* This is a port of the original Java code from ELKI (https://elki-project.github.io/) to Rust,
|
|
19
|
+
* and subsequently ported to JavaScript.
|
|
20
|
+
*
|
|
21
|
+
* If you use this in scientific work, please consider citing above articles.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { arrayAdapter, DenseMatrix, LowerTriangle } from './arrayadapter.mjs';
|
|
25
|
+
export { random_initialization, first_k } from './initialization.mjs';
|
|
26
|
+
export { fasterpam, rand_fasterpam } from './fasterpam.mjs';
|
|
27
|
+
export { fastpam1 } from './fastpam1.mjs';
|
|
28
|
+
export { alternating } from './alternating.mjs';
|
|
29
|
+
export { pam, pam_build, pam_swap } from './pam.mjs';
|
|
30
|
+
export { fastmsc } from './fastmsc.mjs';
|
|
31
|
+
export { fastermsc } from './fastermsc.mjs';
|
|
32
|
+
export { dynmsc } from './dynmsc.mjs';
|
|
33
|
+
export { pamsil, pamsil_swap } from './pamsil.mjs';
|
|
34
|
+
export { pammedsil, pammedsil_swap } from './pammedsil.mjs';
|
|
35
|
+
export { silhouette, medoid_silhouette } from './silhouette.mjs';
|
|
36
|
+
export { par_fasterpam } from './par_fasterpam.mjs';
|
|
37
|
+
export { par_silhouette } from './par_silhouette.mjs';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Initialization helpers, ported from src/initialization.rs (RNG approximated for JS)
|
|
2
|
+
|
|
3
|
+
/** Use the first k objects (0..k-1) as initial medoids. */
|
|
4
|
+
export function first_k(k) {
|
|
5
|
+
return Array.from({ length: k }, (_, i) => i);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Sample k DISTINCT indices from [0, n) using Floyd's algorithm. rng: () => float in [0,1). */
|
|
9
|
+
export function sample(rng, n, k) {
|
|
10
|
+
if (k > n) throw new Error('`amount` of samples must be less than or equal to `length`');
|
|
11
|
+
const chosen = new Set();
|
|
12
|
+
const res = [];
|
|
13
|
+
for (let j = n - k; j < n; j++) {
|
|
14
|
+
const t = Math.floor(rng() * (j + 1)); // 0..j inclusive
|
|
15
|
+
const v = chosen.has(t) ? j : t;
|
|
16
|
+
chosen.add(v);
|
|
17
|
+
res.push(v);
|
|
18
|
+
}
|
|
19
|
+
return res;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Random initialization: k distinct medoid indices in [0, n). */
|
|
23
|
+
export function random_initialization(n, k, rng = Math.random) {
|
|
24
|
+
return sample(rng, n, k);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fisher-Yates shuffle of [0, n). Used by rand_fasterpam / par_fasterpam. */
|
|
28
|
+
export function shuffle(rng, n) {
|
|
29
|
+
const a = Array.from({ length: n }, (_, i) => i);
|
|
30
|
+
for (let i = n - 1; i > 0; i--) {
|
|
31
|
+
const j = Math.floor(rng() * (i + 1));
|
|
32
|
+
const tmp = a[i]; a[i] = a[j]; a[j] = tmp;
|
|
33
|
+
}
|
|
34
|
+
return a;
|
|
35
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// PAM (BUILD and SWAP) algorithm, ported 1:1 from src/pam.rs
|
|
2
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
3
|
+
import { Rec, DistancePair, U32_MAX, USIZE_MAX, choose_medoid_within_partition } from './util.mjs';
|
|
4
|
+
import { initial_assignment, do_swap } from './fasterpam.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run the original PAM SWAP algorithm (no BUILD, but given initial medoids).
|
|
8
|
+
*
|
|
9
|
+
* Provided for academic reasons to see the performance difference.
|
|
10
|
+
*
|
|
11
|
+
* @param mat - a pairwise distance matrix
|
|
12
|
+
* @param med - the list of medoids (mutated in place)
|
|
13
|
+
* @param maxiter - the maximum number of iterations allowed
|
|
14
|
+
* @returns { loss, assi, nIter, nSwaps }
|
|
15
|
+
*/
|
|
16
|
+
export function pam_swap(mat, med, maxiter) {
|
|
17
|
+
mat = arrayAdapter(mat);
|
|
18
|
+
const [loss, data] = initial_assignment(mat, med);
|
|
19
|
+
const [nloss, assi, nIter, nSwaps] = pam_optimize(mat, med, data, maxiter, loss);
|
|
20
|
+
return { loss: nloss, assi, nIter, nSwaps };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run the original PAM BUILD algorithm.
|
|
25
|
+
*
|
|
26
|
+
* Provided for academic reasons to see the performance difference.
|
|
27
|
+
*
|
|
28
|
+
* @param mat - a pairwise distance matrix
|
|
29
|
+
* @param k - the number of medoids to pick
|
|
30
|
+
* @returns { loss, assi, meds }
|
|
31
|
+
*/
|
|
32
|
+
export function pam_build(mat, k) {
|
|
33
|
+
mat = arrayAdapter(mat);
|
|
34
|
+
const n = mat.len();
|
|
35
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
36
|
+
if (!(n <= U32_MAX)) throw new Error('N is too large');
|
|
37
|
+
if (!(k > 0 && k < U32_MAX)) throw new Error('invalid N');
|
|
38
|
+
if (!(k <= n)) throw new Error('k must be at most N');
|
|
39
|
+
const meds = [];
|
|
40
|
+
const data = [];
|
|
41
|
+
const loss = pam_build_initialize(mat, meds, data, k);
|
|
42
|
+
const assi = data.map((x) => x.near.i);
|
|
43
|
+
return { loss, assi, meds };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the original PAM algorithm (BUILD and SWAP).
|
|
48
|
+
*
|
|
49
|
+
* Provided for academic reasons to see the performance difference.
|
|
50
|
+
*
|
|
51
|
+
* @param mat - a pairwise distance matrix
|
|
52
|
+
* @param k - the number of medoids to pick
|
|
53
|
+
* @param maxiter - the maximum number of iterations allowed
|
|
54
|
+
* @returns { loss, assi, meds, nIter, nSwaps }
|
|
55
|
+
*/
|
|
56
|
+
export function pam(mat, k, maxiter) {
|
|
57
|
+
mat = arrayAdapter(mat);
|
|
58
|
+
const n = mat.len();
|
|
59
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
60
|
+
if (!(n <= U32_MAX)) throw new Error('N is too large');
|
|
61
|
+
if (!(k > 0 && k < U32_MAX)) throw new Error('invalid N');
|
|
62
|
+
if (!(k <= n)) throw new Error('k must be at most N');
|
|
63
|
+
const meds = [];
|
|
64
|
+
const data = [];
|
|
65
|
+
const loss = pam_build_initialize(mat, meds, data, k);
|
|
66
|
+
const [nloss, assi, nIter, nSwaps] = pam_optimize(mat, meds, data, maxiter, loss);
|
|
67
|
+
return { loss: nloss, assi, meds, nIter, nSwaps }; // also return medoids
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Main optimization function of PAM, not exposed (use pam_swap or pam). Returns [loss, assi, nIter, nSwaps]. */
|
|
71
|
+
function pam_optimize(mat, med, data, maxiter, loss) {
|
|
72
|
+
const n = mat.len(), k = med.length;
|
|
73
|
+
if (k === 1) {
|
|
74
|
+
const assi = new Array(n).fill(0);
|
|
75
|
+
const [swapped, sloss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
76
|
+
return [sloss, assi, 1, swapped ? 1 : 0];
|
|
77
|
+
}
|
|
78
|
+
let n_swaps = 0, iter = 0;
|
|
79
|
+
while (iter < maxiter) {
|
|
80
|
+
iter += 1;
|
|
81
|
+
let best = [0, k, USIZE_MAX];
|
|
82
|
+
for (let j = 0; j < n; j++) {
|
|
83
|
+
if (j === med[data[j].near.i]) {
|
|
84
|
+
continue; // This already is a medoid
|
|
85
|
+
}
|
|
86
|
+
const [change, b] = find_best_swap_pam(mat, med, data, j);
|
|
87
|
+
if (change >= best[0]) {
|
|
88
|
+
continue; // No improvement
|
|
89
|
+
}
|
|
90
|
+
best = [change, b, j];
|
|
91
|
+
}
|
|
92
|
+
if (best[0] < 0) {
|
|
93
|
+
n_swaps += 1;
|
|
94
|
+
// perform the swap
|
|
95
|
+
const newloss = do_swap(mat, med, data, best[1], best[2]);
|
|
96
|
+
if (newloss >= loss) {
|
|
97
|
+
break; // Probably numerically unstable now.
|
|
98
|
+
}
|
|
99
|
+
loss = newloss;
|
|
100
|
+
} else {
|
|
101
|
+
break; // No improvement, or NaN.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const assi = data.map((x) => x.near.i);
|
|
105
|
+
return [loss, assi, iter, n_swaps];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Find the best swap for object j - slower PAM version. Returns [acc_best, m_best]. */
|
|
109
|
+
function find_best_swap_pam(mat, med, data, j) {
|
|
110
|
+
const recj = data[j];
|
|
111
|
+
let best = [0, USIZE_MAX];
|
|
112
|
+
for (let m = 0; m < med.length; m++) {
|
|
113
|
+
let acc = -recj.near.d; // j becomes medoid
|
|
114
|
+
for (let o = 0; o < data.length; o++) {
|
|
115
|
+
const reco = data[o];
|
|
116
|
+
if (o === j) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const doj = mat.get(o, j);
|
|
120
|
+
// Current medoid is being replaced:
|
|
121
|
+
if (reco.near.i === m) {
|
|
122
|
+
if (doj < reco.seco.d) {
|
|
123
|
+
// Assign to new medoid:
|
|
124
|
+
acc += doj - reco.near.d;
|
|
125
|
+
} else {
|
|
126
|
+
// Assign to second nearest instead:
|
|
127
|
+
acc += reco.seco.d - reco.near.d;
|
|
128
|
+
}
|
|
129
|
+
} else if (doj < reco.near.d) {
|
|
130
|
+
// new mediod is closer:
|
|
131
|
+
acc += doj - reco.near.d;
|
|
132
|
+
} // else no change
|
|
133
|
+
}
|
|
134
|
+
if (acc < best[0]) {
|
|
135
|
+
best = [acc, m];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Not exposed. Use pam_build or pam. Pushes into medsArr (numbers) and dataArr (Rec). Returns loss. */
|
|
142
|
+
export function pam_build_initialize(mat, meds, data, k) {
|
|
143
|
+
const n = mat.len();
|
|
144
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
145
|
+
// choose first medoid
|
|
146
|
+
let best = [0, k];
|
|
147
|
+
for (let i = 0; i < n; i++) {
|
|
148
|
+
let sum = 0;
|
|
149
|
+
for (let j = 0; j < n; j++) {
|
|
150
|
+
if (j !== i) {
|
|
151
|
+
sum += mat.get(j, i);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (i === 0 || sum < best[0]) {
|
|
155
|
+
best = [sum, i];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
let loss = best[0];
|
|
159
|
+
meds.push(best[1]);
|
|
160
|
+
for (let j = 0; j < n; j++) {
|
|
161
|
+
data.push(new Rec(0, mat.get(j, best[1]), U32_MAX, 0));
|
|
162
|
+
}
|
|
163
|
+
// choose remaining medoids
|
|
164
|
+
for (let l = 1; l < k; l++) {
|
|
165
|
+
best = [0, k];
|
|
166
|
+
for (let i = 0; i < data.length; i++) {
|
|
167
|
+
let sum = -data[i].near.d;
|
|
168
|
+
for (let j = 0; j < data.length; j++) {
|
|
169
|
+
const dj = data[j];
|
|
170
|
+
if (j !== i) {
|
|
171
|
+
const d = mat.get(j, i);
|
|
172
|
+
if (d < dj.near.d) {
|
|
173
|
+
sum += d - dj.near.d;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (i === 0 || sum < best[0]) {
|
|
178
|
+
best = [sum, i];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (best[0] >= 0) { break; } // No further improvements - duplicates etc.
|
|
182
|
+
// Update assignments:
|
|
183
|
+
loss = 0;
|
|
184
|
+
for (let j = 0; j < data.length; j++) {
|
|
185
|
+
const recj = data[j];
|
|
186
|
+
if (j === best[1]) {
|
|
187
|
+
recj.seco = recj.near.clone();
|
|
188
|
+
recj.near = new DistancePair(l, 0);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const dj = mat.get(j, best[1]);
|
|
192
|
+
if (dj < recj.near.d) {
|
|
193
|
+
recj.seco = recj.near.clone();
|
|
194
|
+
recj.near = new DistancePair(l, dj);
|
|
195
|
+
} else if (recj.seco.i === U32_MAX || dj < recj.seco.d) {
|
|
196
|
+
recj.seco = new DistancePair(l, dj);
|
|
197
|
+
}
|
|
198
|
+
loss += recj.near.d;
|
|
199
|
+
}
|
|
200
|
+
meds.push(best[1]);
|
|
201
|
+
}
|
|
202
|
+
return loss;
|
|
203
|
+
}
|