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,235 @@
|
|
|
1
|
+
// Ported 1:1 from src/pammedsil.rs (PAMMEDSIL algorithm).
|
|
2
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
3
|
+
import { Reco, DistancePair, U32_MAX, USIZE_MAX, choose_medoid_within_partition } from './util.mjs';
|
|
4
|
+
import { _loss, initial_assignment, do_swap } from './fastermsc.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run the original PAMMEDSIL SWAP algorithm (no initialization, but given initial medoids).
|
|
8
|
+
* @param {object} mat - pairwise distance matrix
|
|
9
|
+
* @param {number[]} med - the list of medoids (mutated in place)
|
|
10
|
+
* @param {number} maxiter - the maximum number of iterations allowed
|
|
11
|
+
* returns { loss, assi, nIter, nSwaps }
|
|
12
|
+
*/
|
|
13
|
+
export function pammedsil_swap(mat, med, maxiter) {
|
|
14
|
+
mat = arrayAdapter(mat);
|
|
15
|
+
const [loss, data] = initial_assignment(mat, med);
|
|
16
|
+
return pammedsil_optimize(mat, med, data, maxiter, loss);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run the original PAM BUILD algorithm combined with the PAMMEDSIL SWAP.
|
|
21
|
+
* @param {object} mat - pairwise distance matrix
|
|
22
|
+
* @param {number} k - the number of medoids to pick
|
|
23
|
+
* @param {number} maxiter - the maximum number of iterations allowed
|
|
24
|
+
* returns { loss, assi, meds, nIter, nSwaps }
|
|
25
|
+
*/
|
|
26
|
+
export function pammedsil(mat, k, maxiter) {
|
|
27
|
+
mat = arrayAdapter(mat);
|
|
28
|
+
const n = mat.len();
|
|
29
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
30
|
+
if (!(n <= U32_MAX)) throw new Error('N is too large');
|
|
31
|
+
if (!(k > 0 && k < U32_MAX)) throw new Error('invalid N');
|
|
32
|
+
if (!(k <= n)) throw new Error('k must be at most N');
|
|
33
|
+
const meds = [];
|
|
34
|
+
const data = [];
|
|
35
|
+
const loss = pammedsil_build_initialize(mat, meds, data, k);
|
|
36
|
+
const { loss: nloss, assi, nIter, nSwaps } = pammedsil_optimize(mat, meds, data, maxiter, loss);
|
|
37
|
+
return { loss: nloss, assi, meds, nIter, nSwaps }; // also return medoids
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Main optimization function of PAMMEDSIL, not exposed (use pammedsil_swap or pammedsil) */
|
|
41
|
+
function pammedsil_optimize(mat, med, data, maxiter, loss) {
|
|
42
|
+
const n = mat.len(), k = med.length;
|
|
43
|
+
if (k === 1) {
|
|
44
|
+
const assi = new Array(n).fill(0);
|
|
45
|
+
const [swapped, lloss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
46
|
+
return { loss: lloss, assi, nIter: 1, nSwaps: swapped ? 1 : 0 };
|
|
47
|
+
}
|
|
48
|
+
let n_swaps = 0, iter = 0;
|
|
49
|
+
while (iter < maxiter) {
|
|
50
|
+
iter += 1;
|
|
51
|
+
let best = [0, k, USIZE_MAX];
|
|
52
|
+
for (let j = 0; j < n; j++) {
|
|
53
|
+
if (j === med[data[j].near.i]) {
|
|
54
|
+
continue; // This already is a medoid
|
|
55
|
+
}
|
|
56
|
+
const [change, b] = (k === 2)
|
|
57
|
+
? find_best_swap_pammedsil_k2(mat, med, data, j)
|
|
58
|
+
: find_best_swap_pammedsil(mat, med, data, j);
|
|
59
|
+
if (change <= best[0]) {
|
|
60
|
+
continue; // No improvement
|
|
61
|
+
}
|
|
62
|
+
best = [change, b, j];
|
|
63
|
+
}
|
|
64
|
+
if (best[0] > 0) {
|
|
65
|
+
n_swaps += 1;
|
|
66
|
+
// perform the swap
|
|
67
|
+
const newloss = do_swap(mat, med, data, best[1], best[2]);
|
|
68
|
+
if (newloss >= loss) {
|
|
69
|
+
break; // Probably numerically unstable now.
|
|
70
|
+
}
|
|
71
|
+
loss = newloss;
|
|
72
|
+
} else {
|
|
73
|
+
break; // No improvement, or NaN.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const assi = data.map((x) => x.near.i);
|
|
77
|
+
loss = 1 - loss / n;
|
|
78
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Find the best swap for object j. Returns [change, b]. */
|
|
82
|
+
function find_best_swap_pammedsil(mat, med, data, j) {
|
|
83
|
+
const recj = data[j];
|
|
84
|
+
let best = [0, USIZE_MAX];
|
|
85
|
+
for (let m = 0; m < med.length; m++) {
|
|
86
|
+
let acc = _loss(recj.near.d, recj.seco.d); // j becomes medoid
|
|
87
|
+
for (let o = 0; o < data.length; o++) {
|
|
88
|
+
const reco = data[o];
|
|
89
|
+
if (o === j) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const doj = mat.get(o, j);
|
|
93
|
+
// Current medoid is being replaced:
|
|
94
|
+
if (reco.near.i === m) {
|
|
95
|
+
if (doj < reco.seco.d) {
|
|
96
|
+
// Assign to new medoid:
|
|
97
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(doj, reco.seco.d);
|
|
98
|
+
} else if (doj < reco.third.d) {
|
|
99
|
+
// Assign to second nearest instead:
|
|
100
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.seco.d, doj);
|
|
101
|
+
} else {
|
|
102
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.seco.d, reco.third.d);
|
|
103
|
+
}
|
|
104
|
+
} else if (reco.seco.i === m) {
|
|
105
|
+
if (doj < reco.near.d) {
|
|
106
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(doj, reco.near.d);
|
|
107
|
+
} else if (doj < reco.third.d) {
|
|
108
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.near.d, doj);
|
|
109
|
+
} else {
|
|
110
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.near.d, reco.third.d);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
if (doj < reco.near.d) {
|
|
114
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(doj, reco.near.d);
|
|
115
|
+
} else if (doj < reco.seco.d) {
|
|
116
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.near.d, doj);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (acc > best[0]) {
|
|
121
|
+
best = [acc, m];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Find the best swap for object j (k=2 variant). Returns [change, b]. */
|
|
128
|
+
function find_best_swap_pammedsil_k2(mat, med, data, j) {
|
|
129
|
+
const recj = data[j];
|
|
130
|
+
let best = [0, USIZE_MAX];
|
|
131
|
+
for (let m = 0; m < med.length; m++) {
|
|
132
|
+
let acc = _loss(recj.near.d, recj.seco.d); // j becomes medoid
|
|
133
|
+
for (let o = 0; o < data.length; o++) {
|
|
134
|
+
const reco = data[o];
|
|
135
|
+
if (o === j) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const doj = mat.get(o, j);
|
|
139
|
+
// Current medoid is being replaced:
|
|
140
|
+
if (reco.near.i === m) {
|
|
141
|
+
if (doj < reco.seco.d) {
|
|
142
|
+
// Assign to new medoid:
|
|
143
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(doj, reco.seco.d);
|
|
144
|
+
} else {
|
|
145
|
+
// Assign to second nearest instead:
|
|
146
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.seco.d, doj);
|
|
147
|
+
}
|
|
148
|
+
} else if (reco.seco.i === m) {
|
|
149
|
+
if (doj < reco.near.d) {
|
|
150
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(doj, reco.near.d);
|
|
151
|
+
} else {
|
|
152
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.near.d, doj);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
if (doj < reco.near.d) {
|
|
156
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(doj, reco.near.d);
|
|
157
|
+
} else if (doj < reco.seco.d) {
|
|
158
|
+
acc += _loss(reco.near.d, reco.seco.d) - _loss(reco.near.d, doj);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (acc > best[0]) {
|
|
163
|
+
best = [acc, m];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return best;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Not exposed. Use pammedsil_build or pammedsil. Pushes into meds & data. Returns loss. */
|
|
170
|
+
function pammedsil_build_initialize(mat, meds, data, k) {
|
|
171
|
+
const n = mat.len();
|
|
172
|
+
// choose first medoid
|
|
173
|
+
let best = [0, k];
|
|
174
|
+
for (let i = 0; i < n; i++) {
|
|
175
|
+
let sum = 0;
|
|
176
|
+
for (let j = 0; j < n; j++) {
|
|
177
|
+
if (j !== i) {
|
|
178
|
+
sum += mat.get(j, i);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (i === 0 || sum < best[0]) {
|
|
182
|
+
best = [sum, i];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let loss = best[0];
|
|
186
|
+
meds.push(best[1]);
|
|
187
|
+
for (let j = 0; j < n; j++) {
|
|
188
|
+
data.push(new Reco(0, mat.get(j, best[1]), U32_MAX, 0, U32_MAX, 0));
|
|
189
|
+
}
|
|
190
|
+
// choose remaining medoids
|
|
191
|
+
for (let l = 1; l < k; l++) {
|
|
192
|
+
best = [0, k];
|
|
193
|
+
for (let i = 1; i < data.length; i++) {
|
|
194
|
+
let sum = -data[i].near.d;
|
|
195
|
+
for (let jj = 0; jj < data.length; jj++) {
|
|
196
|
+
const dj = data[jj];
|
|
197
|
+
if (jj !== i) {
|
|
198
|
+
const d = mat.get(jj, i);
|
|
199
|
+
if (d < dj.near.d) {
|
|
200
|
+
sum += d - dj.near.d;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (i === 0 || sum < best[0]) {
|
|
205
|
+
best = [sum, i];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (best[0] >= 0) { break; } // No more improvement, duplicates
|
|
209
|
+
// Update assignments:
|
|
210
|
+
loss = 0;
|
|
211
|
+
for (let jj = 0; jj < data.length; jj++) {
|
|
212
|
+
const recj = data[jj];
|
|
213
|
+
if (jj === best[1]) {
|
|
214
|
+
recj.third = recj.seco.clone();
|
|
215
|
+
recj.seco = recj.near.clone();
|
|
216
|
+
recj.near = new DistancePair(l, 0);
|
|
217
|
+
} else {
|
|
218
|
+
const dj = mat.get(jj, best[1]);
|
|
219
|
+
if (dj < recj.near.d) {
|
|
220
|
+
recj.third = recj.seco.clone();
|
|
221
|
+
recj.seco = recj.near.clone();
|
|
222
|
+
recj.near = new DistancePair(l, dj);
|
|
223
|
+
} else if (recj.seco.i === U32_MAX || dj < recj.seco.d) {
|
|
224
|
+
recj.third = recj.seco.clone();
|
|
225
|
+
recj.seco = new DistancePair(l, dj);
|
|
226
|
+
} else if (recj.third.i === U32_MAX || dj < recj.third.d) {
|
|
227
|
+
recj.third = new DistancePair(l, dj);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
loss += _loss(recj.near.d, recj.seco.d);
|
|
231
|
+
}
|
|
232
|
+
meds.push(best[1]);
|
|
233
|
+
}
|
|
234
|
+
return loss;
|
|
235
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// PAMSIL: PAM combined with direct Silhouette optimization.
|
|
2
|
+
// Ported 1:1 from src/pamsil.rs
|
|
3
|
+
|
|
4
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
5
|
+
import { USIZE_MAX, choose_medoid_within_partition } from './util.mjs';
|
|
6
|
+
import { assign_nearest } from './alternating.mjs';
|
|
7
|
+
import { silhouette } from './silhouette.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run the original PAMSIL SWAP algorithm (no BUILD, but given initial medoids).
|
|
11
|
+
*
|
|
12
|
+
* @param {*} 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: number, assi: number[], nIter: number, nSwaps: number }}
|
|
16
|
+
*/
|
|
17
|
+
export function pamsil(mat, k, maxiter) {
|
|
18
|
+
mat = arrayAdapter(mat);
|
|
19
|
+
const n = mat.len();
|
|
20
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
21
|
+
if (!(n <= 4294967295)) throw new Error('N is too large');
|
|
22
|
+
if (!(k > 0 && k < 4294967295)) throw new Error('invalid N');
|
|
23
|
+
if (!(k <= n)) throw new Error('k must be at most N');
|
|
24
|
+
const meds = [];
|
|
25
|
+
const assi = new Array(n).fill(0);
|
|
26
|
+
pamsil_build_initialize(mat, meds, assi, k);
|
|
27
|
+
const [nloss, n_iter, n_swap] = pamsil_optimize(mat, meds, assi, maxiter);
|
|
28
|
+
return { loss: nloss, assi, meds, nIter: n_iter, nSwaps: n_swap }; // also return medoids
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run the original PAMSIL SWAP algorithm (no BUILD, but given initial medoids).
|
|
33
|
+
*
|
|
34
|
+
* @param {*} mat - a pairwise distance matrix
|
|
35
|
+
* @param {number[]} med - the list of medoids (mutated in place)
|
|
36
|
+
* @param {number} maxiter - the maximum number of iterations allowed
|
|
37
|
+
* @returns {{ loss: number, assi: number[], nIter: number, nSwaps: number }}
|
|
38
|
+
*/
|
|
39
|
+
export function pamsil_swap(mat, med, maxiter) {
|
|
40
|
+
mat = arrayAdapter(mat);
|
|
41
|
+
const n = mat.len();
|
|
42
|
+
const assi = new Array(n).fill(0);
|
|
43
|
+
assign_nearest(mat, med, assi);
|
|
44
|
+
const [nloss, n_iter, n_swap] = pamsil_optimize(mat, med, assi, maxiter);
|
|
45
|
+
return { loss: nloss, assi, nIter: n_iter, nSwaps: n_swap };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Main optimization function of PAMSIL, not exposed (use pamsil_swap or pamsil).
|
|
50
|
+
* @returns {[number, number, number]} [loss, n_iter, n_swaps]
|
|
51
|
+
*/
|
|
52
|
+
function pamsil_optimize(mat, med, assi, maxiter) {
|
|
53
|
+
const n = mat.len(), k = med.length;
|
|
54
|
+
if (k === 1) {
|
|
55
|
+
const [swapped, loss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
56
|
+
return [loss, 1, swapped ? 1 : 0];
|
|
57
|
+
}
|
|
58
|
+
let n_swaps = 0, iter = 0;
|
|
59
|
+
let sil = silhouette(mat, assi, false).sil;
|
|
60
|
+
while (iter < maxiter) {
|
|
61
|
+
iter += 1;
|
|
62
|
+
let best = [0, k, USIZE_MAX];
|
|
63
|
+
for (let m = 0; m < k; m++) {
|
|
64
|
+
const medm = med[m]; // preseve previous value
|
|
65
|
+
for (let j = 0; j < n; j++) {
|
|
66
|
+
if (j === medm || j === med[assi[j]]) {
|
|
67
|
+
continue; // This already is a medoid
|
|
68
|
+
}
|
|
69
|
+
med[m] = j; // replace
|
|
70
|
+
assign_nearest(mat, med, assi);
|
|
71
|
+
const siltemp = silhouette(mat, assi, false).sil;
|
|
72
|
+
if (siltemp <= best[0]) {
|
|
73
|
+
continue; // No improvement
|
|
74
|
+
}
|
|
75
|
+
best = [siltemp, m, j];
|
|
76
|
+
}
|
|
77
|
+
med[m] = medm; // restore
|
|
78
|
+
}
|
|
79
|
+
if (best[0] <= sil) {
|
|
80
|
+
break; // no improvement
|
|
81
|
+
}
|
|
82
|
+
n_swaps += 1;
|
|
83
|
+
med[best[1]] = best[2];
|
|
84
|
+
sil = best[0];
|
|
85
|
+
}
|
|
86
|
+
assign_nearest(mat, med, assi);
|
|
87
|
+
return [sil, iter, n_swaps];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Not exposed. Use pamsil_build or pamsil.
|
|
92
|
+
* Standard PAM BUILD storing nearest distance per point in a plain number array `data`.
|
|
93
|
+
* @returns {number} loss
|
|
94
|
+
*/
|
|
95
|
+
function pamsil_build_initialize(mat, meds, assi, k) {
|
|
96
|
+
const n = mat.len();
|
|
97
|
+
// choose first medoid
|
|
98
|
+
let best = [0, k];
|
|
99
|
+
for (let i = 0; i < n; i++) {
|
|
100
|
+
let sum = 0;
|
|
101
|
+
for (let j = 0; j < n; j++) {
|
|
102
|
+
if (j !== i) {
|
|
103
|
+
sum += mat.get(j, i);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (i === 0 || sum < best[0]) {
|
|
107
|
+
best = [sum, i];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let loss = best[0];
|
|
111
|
+
meds.push(best[1]);
|
|
112
|
+
const data = [];
|
|
113
|
+
assi.fill(0);
|
|
114
|
+
for (let j = 0; j < n; j++) {
|
|
115
|
+
data.push(mat.get(j, best[1]));
|
|
116
|
+
}
|
|
117
|
+
// choose remaining medoids
|
|
118
|
+
for (let _ = 1; _ < k; _++) {
|
|
119
|
+
best = [0, k];
|
|
120
|
+
for (let i = 0; i < data.length; i++) {
|
|
121
|
+
const di = data[i];
|
|
122
|
+
let sum = -di;
|
|
123
|
+
for (let j = 0; j < data.length; j++) {
|
|
124
|
+
const dnear = data[j];
|
|
125
|
+
if (j !== i) {
|
|
126
|
+
const d = mat.get(j, i);
|
|
127
|
+
if (d < dnear) {
|
|
128
|
+
sum += d - dnear;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (i === 0 || sum < best[0]) {
|
|
133
|
+
best = [sum, i];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!(best[0] <= 0)) throw new Error('assertion failed: best.0 <= L::zero()');
|
|
137
|
+
// Update assignments:
|
|
138
|
+
loss = 0;
|
|
139
|
+
for (let j = 0; j < data.length; j++) {
|
|
140
|
+
if (j === best[1]) {
|
|
141
|
+
data[j] = 0;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const dj = mat.get(j, best[1]);
|
|
145
|
+
if (dj < data[j]) {
|
|
146
|
+
data[j] = dj;
|
|
147
|
+
}
|
|
148
|
+
loss += data[j];
|
|
149
|
+
}
|
|
150
|
+
meds.push(best[1]);
|
|
151
|
+
}
|
|
152
|
+
return loss;
|
|
153
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Parallel FasterPAM algorithm, ported 1:1 from src/par_fasterpam.rs
|
|
2
|
+
// JS is single-threaded; parallelism is collapsed to a sequential equivalent
|
|
3
|
+
// by delegating to fasterpam.mjs helpers (result-identical to the Rust parallel internals).
|
|
4
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
5
|
+
import { choose_medoid_within_partition } from './util.mjs';
|
|
6
|
+
import { initial_assignment, find_best_swap, do_swap, update_removal_loss } from './fasterpam.mjs';
|
|
7
|
+
import { shuffle } from './initialization.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run the FasterPAM algorithm (parallel version).
|
|
11
|
+
*
|
|
12
|
+
* For small data sets (n<1000) it is usually faster to use the non-parallel version.
|
|
13
|
+
*
|
|
14
|
+
* @param mat - a pairwise distance matrix
|
|
15
|
+
* @param med - the list of medoids (mutated in place)
|
|
16
|
+
* @param maxiter - the maximum number of iterations allowed
|
|
17
|
+
* @param rng - random number generator for shuffling the input data
|
|
18
|
+
* @returns { loss, assi, nIter, nSwaps }
|
|
19
|
+
*
|
|
20
|
+
* Panics (throws) when the dissimilarity matrix is not square, or k is 0 or larger than N.
|
|
21
|
+
*/
|
|
22
|
+
export function par_fasterpam(mat, med, maxiter, rng = Math.random) {
|
|
23
|
+
mat = arrayAdapter(mat);
|
|
24
|
+
const n = mat.len(), k = med.length;
|
|
25
|
+
if (k === 1) {
|
|
26
|
+
const assi = new Array(n).fill(0);
|
|
27
|
+
const [swapped, loss] = choose_medoid_within_partition(mat, assi, med, 0);
|
|
28
|
+
return { loss, assi, nIter: 1, nSwaps: swapped ? 1 : 0 };
|
|
29
|
+
}
|
|
30
|
+
let [loss, data] = initial_assignment(mat, med);
|
|
31
|
+
|
|
32
|
+
let removal_loss = new Array(k).fill(0);
|
|
33
|
+
update_removal_loss(data, removal_loss);
|
|
34
|
+
let lastswap = n, n_swaps = 0, iter = 0;
|
|
35
|
+
const seq = shuffle(rng, n); // random shuffling
|
|
36
|
+
while (iter < maxiter) {
|
|
37
|
+
iter += 1;
|
|
38
|
+
const swaps_before = n_swaps, lastloss = loss;
|
|
39
|
+
for (const j of seq) {
|
|
40
|
+
if (j === lastswap) {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
if (j === med[data[j].near.i]) {
|
|
44
|
+
continue; // This already is a medoid
|
|
45
|
+
}
|
|
46
|
+
const [change, b] = find_best_swap(mat, removal_loss, data, j);
|
|
47
|
+
if (change >= 0) {
|
|
48
|
+
continue; // No improvement
|
|
49
|
+
}
|
|
50
|
+
n_swaps += 1;
|
|
51
|
+
lastswap = j;
|
|
52
|
+
// perform the swap
|
|
53
|
+
loss = do_swap(mat, med, data, b, j);
|
|
54
|
+
update_removal_loss(data, removal_loss);
|
|
55
|
+
}
|
|
56
|
+
if (n_swaps === swaps_before || loss >= lastloss) {
|
|
57
|
+
break; // converged
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const assi = data.map((x) => x.near.i);
|
|
61
|
+
return { loss, assi, nIter: iter, nSwaps: n_swaps };
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Parallelism (rayon) collapsed to sequential equivalent for JS.
|
|
2
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
3
|
+
import { checked_div } from './silhouette.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compute the Silhouette of a strict partitional clustering (sequential JS port of parallel Rust impl).
|
|
7
|
+
*
|
|
8
|
+
* @param {object|Array} mat - pairwise distance matrix (will be wrapped by arrayAdapter)
|
|
9
|
+
* @param {number[]} assi - cluster assignment for each point
|
|
10
|
+
* @returns {number} the average silhouette value
|
|
11
|
+
*/
|
|
12
|
+
export function par_silhouette(mat, assi) {
|
|
13
|
+
mat = arrayAdapter(mat);
|
|
14
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
15
|
+
let lsum = 0;
|
|
16
|
+
for (let i = 0; i < assi.length; i++) {
|
|
17
|
+
const ai = assi[i];
|
|
18
|
+
// buf[c] = [count, sum] for cluster c
|
|
19
|
+
const buf = [];
|
|
20
|
+
for (let j = 0; j < assi.length; j++) {
|
|
21
|
+
const aj = assi[j];
|
|
22
|
+
while (aj >= buf.length) {
|
|
23
|
+
buf.push([0, 0]);
|
|
24
|
+
}
|
|
25
|
+
if (i !== j) {
|
|
26
|
+
buf[aj][0] += 1;
|
|
27
|
+
buf[aj][1] += mat.get(i, j);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (buf[ai][0] > 0) {
|
|
31
|
+
const a = checked_div(buf[ai][1], buf[ai][0]);
|
|
32
|
+
// find minimum average distance to any other cluster (b)
|
|
33
|
+
// mirrors Rust: tmp.next().unwrap_or_else(L::zero) seeds the fold
|
|
34
|
+
let tmp2 = 0;
|
|
35
|
+
let foundFirst = false;
|
|
36
|
+
let b = 0;
|
|
37
|
+
for (let c = 0; c < buf.length; c++) {
|
|
38
|
+
if (c === ai) continue;
|
|
39
|
+
const avg = checked_div(buf[c][1], buf[c][0]);
|
|
40
|
+
if (!foundFirst) {
|
|
41
|
+
tmp2 = avg;
|
|
42
|
+
foundFirst = true;
|
|
43
|
+
b = tmp2;
|
|
44
|
+
} else {
|
|
45
|
+
b = avg < b ? avg : b;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// if no other cluster exists, foundFirst is false → b = tmp2 = 0
|
|
49
|
+
lsum += checked_div(b - a, a > b ? a : b);
|
|
50
|
+
}
|
|
51
|
+
// else: singleton, contributes 0 (lsum unchanged)
|
|
52
|
+
}
|
|
53
|
+
return lsum / assi.length;
|
|
54
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Silhouette evaluation measures, ported from src/silhouette.rs
|
|
2
|
+
import { arrayAdapter } from './arrayadapter.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compute the Silhouette of a strict partitional clustering.
|
|
6
|
+
*
|
|
7
|
+
* The Silhouette, proposed by Peter Rousseeuw in 1987, is a popular internal
|
|
8
|
+
* evaluation measure for clusterings.
|
|
9
|
+
*
|
|
10
|
+
* @param mat - a pairwise distance matrix
|
|
11
|
+
* @param assi - the cluster assignment
|
|
12
|
+
* @param samples - whether to keep the individual samples, or not
|
|
13
|
+
* @returns { sil, samples } where sil is the average silhouette and samples are
|
|
14
|
+
* the individual silhouette values (empty [] if samples = false)
|
|
15
|
+
*/
|
|
16
|
+
export function silhouette(mat, assi, samples = false) {
|
|
17
|
+
mat = arrayAdapter(mat);
|
|
18
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
19
|
+
let sil = new Array(samples ? assi.length : 0).fill(0);
|
|
20
|
+
let lsum = 0;
|
|
21
|
+
let buf = []; // array of [count(u32), sum(L)] pairs
|
|
22
|
+
for (let i = 0; i < assi.length; i++) {
|
|
23
|
+
const ai = assi[i];
|
|
24
|
+
buf.length = 0;
|
|
25
|
+
for (let j = 0; j < assi.length; j++) {
|
|
26
|
+
const aj = assi[j];
|
|
27
|
+
while (aj >= buf.length) {
|
|
28
|
+
buf.push([0, 0]);
|
|
29
|
+
}
|
|
30
|
+
if (i !== j) {
|
|
31
|
+
buf[aj][0] += 1;
|
|
32
|
+
buf[aj][1] += mat.get(i, j);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (buf.length === 1) {
|
|
36
|
+
return { sil: 0, samples: sil };
|
|
37
|
+
}
|
|
38
|
+
let s;
|
|
39
|
+
if (buf[ai][0] > 0) {
|
|
40
|
+
const a = checked_div(buf[ai][1], buf[ai][0]);
|
|
41
|
+
// Ugly hack to get the min():
|
|
42
|
+
let started = false;
|
|
43
|
+
let b = 0;
|
|
44
|
+
for (let k = 0; k < buf.length; k++) {
|
|
45
|
+
if (k === ai) continue;
|
|
46
|
+
const p = buf[k];
|
|
47
|
+
const y = checked_div(p[1], p[0]);
|
|
48
|
+
if (!started) {
|
|
49
|
+
b = y; // tmp.next().unwrap_or_else(L::zero)
|
|
50
|
+
started = true;
|
|
51
|
+
} else {
|
|
52
|
+
b = y < b ? y : b; // tmp.fold(tmp2, |x, y| if y < x { y } else { x })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// tmp2 = tmp.next().unwrap_or_else(L::zero): if no element, b stays 0
|
|
56
|
+
s = checked_div(b - a, a > b ? a : b);
|
|
57
|
+
} else {
|
|
58
|
+
s = 0; // singleton
|
|
59
|
+
}
|
|
60
|
+
if (samples) {
|
|
61
|
+
sil[i] = s;
|
|
62
|
+
}
|
|
63
|
+
lsum += s;
|
|
64
|
+
}
|
|
65
|
+
return { sil: lsum / assi.length, samples: sil };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute the Medoid Silhouette of a clustering.
|
|
70
|
+
*
|
|
71
|
+
* The Medoid Silhouette is an approximation to the original Silhouette where the
|
|
72
|
+
* distance to the cluster medoid is used instead of the average distance.
|
|
73
|
+
*
|
|
74
|
+
* @param mat - a pairwise distance matrix
|
|
75
|
+
* @param meds - the medoid list
|
|
76
|
+
* @param samples - whether to keep the individual samples, or not
|
|
77
|
+
* @returns { sil, samples } where sil is the average medoid silhouette and
|
|
78
|
+
* samples are the individual values (empty [] if samples = false)
|
|
79
|
+
*/
|
|
80
|
+
export function medoid_silhouette(mat, meds, samples = false) {
|
|
81
|
+
mat = arrayAdapter(mat);
|
|
82
|
+
const n = mat.len(), k = meds.length;
|
|
83
|
+
if (!mat.isSquare()) throw new Error('Dissimilarity matrix is not square');
|
|
84
|
+
if (!(n <= U32_MAX_GUARD)) throw new Error('N is too large');
|
|
85
|
+
let sil = new Array(samples ? n : 0).fill(1);
|
|
86
|
+
if (k === 1) { return { sil: 1, samples: sil }; } // not really well-defined
|
|
87
|
+
if (!(k >= 2 && k <= n)) throw new Error('invalid k, must be over 1 and at most N');
|
|
88
|
+
let loss = 0;
|
|
89
|
+
for (let i = 0; i < n; i++) {
|
|
90
|
+
const d1 = mat.get(i, meds[0]), d2 = mat.get(i, meds[1]);
|
|
91
|
+
let best = d1 < d2 ? [d1, d2] : [d2, d1];
|
|
92
|
+
for (let s2 = 2; s2 < meds.length; s2++) {
|
|
93
|
+
const m = meds[s2];
|
|
94
|
+
const d = mat.get(i, m);
|
|
95
|
+
if (d < best[0]) {
|
|
96
|
+
best = [d, best[0]];
|
|
97
|
+
} else if (d < best[1]) {
|
|
98
|
+
best = [best[0], d];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (best[0] !== 0) {
|
|
102
|
+
const s = best[0] / best[1];
|
|
103
|
+
if (samples) { sil[i] = 1 - s; }
|
|
104
|
+
loss += s;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
loss = 1 - loss / n;
|
|
108
|
+
return { sil: loss, samples: sil };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// u32::MAX, used only for the "N is too large" assertion.
|
|
112
|
+
const U32_MAX_GUARD = 4294967295;
|
|
113
|
+
|
|
114
|
+
// helper function, returns 0 on division by 0
|
|
115
|
+
export function checked_div(x, y) {
|
|
116
|
+
if (y > 0) {
|
|
117
|
+
return x / y;
|
|
118
|
+
} else {
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
}
|