als-statistics
Version:
Modular JS statistics toolkit for Node.js and the browser: descriptive stats, correlations (Pearson/Spearman/Kendall), t-tests & ANOVA (Student/Welch), reliability (Cronbach’s alpha), regression (linear/logistic), clustering (DBSCAN/HDBSCAN), and table/co
204 lines (178 loc) • 8.99 kB
JavaScript
import computeDistances from './compute-dist.js';
import { buildClusters } from './build-clusters.js'
import { TestBase } from '../test-base/index.js'
export default class Hdbscan extends TestBase {
constructor(samples, options = {}) {
super(samples, 'Hdbscan', [], { min: 2 })
const { metric = 'mad', minClusterSize = 2 } = options
this.metric = metric;
this.minClusterSize = Math.max(2, minClusterSize);
this.labels = new Array(this.k).fill(-1);
this.clusters = [];
this.mreachDistances = null;
this.mst = [];
this.hierarchy = []; // узлы дерева: { clusterId, lambdaBirth, lambdaDeath, points, size, children:[] }
this.distances = computeDistances(this.samples, this.metric);
this.computeMutualReachability();
this.buildMST();
this.buildHierarchy();
this.extractClusters();
buildClusters(this);
}
computeMutualReachability() {
this.mreachDistances = Array.from({ length: this.k }, () => Array(this.k).fill(0));
const k = Math.min(this.minClusterSize, this.k); // безопасно при k>n
const coreDistances = new Array(this.k);
for (let i = 0; i < this.k; i++) {
const sorted = this.distances[i].slice().sort((a, b) => a - b);
coreDistances[i] = sorted[k - 1]; // 1-based → 0-based индекс
}
for (let i = 0; i < this.k; i++) {
for (let j = i; j < this.k; j++) {
const mreach = Math.max(coreDistances[i], coreDistances[j], this.distances[i][j]);
this.mreachDistances[i][j] = mreach;
this.mreachDistances[j][i] = mreach;
}
}
}
buildMST() {
const visited = new Array(this.k).fill(false);
const key = new Array(this.k).fill(Infinity);
const parent = new Array(this.k).fill(-1);
key[0] = 0;
this.mst = [];
for (let count = 0; count < this.k; count++) {
let minKey = Infinity;
let u = -1;
for (let v = 0; v < this.k; v++) {
if (!visited[v] && key[v] < minKey) { minKey = key[v]; u = v; }
}
if (u === -1) break;
visited[u] = true;
if (parent[u] !== -1) this.mst.push([parent[u], u, this.mreachDistances[parent[u]][u]]);
for (let v = 0; v < this.k; v++) {
if (!visited[v] && this.mreachDistances[u][v] < key[v]) {
key[v] = this.mreachDistances[u][v];
parent[v] = u;
}
}
}
if (this.mst.length > this.k - 1) this.mst.length = this.k - 1; // На всякий случай (хотя Prim даёт ≤ n-1):
this.mst.sort((a, b) => a[2] - b[2]);
}
buildHierarchy() {
const uf = new UnionFind(this.k);
// Инициализируем листья (синглтоны). mapRootToNode: id корня в UF -> индекс узла в this.hierarchy
const mapRootToNode = new Array(this.k);
for (let i = 0; i < this.k; i++) {
this.hierarchy.push({ clusterId: i, lambdaBirth: 0, lambdaDeath: null, points: [i], size: 1, children: [] });
mapRootToNode[i] = i;
}
for (const [p, q, weight] of this.mst) { // Идём по рёбрам MST в порядке возрастания веса (level)
const level = weight; // без 1/weight — так «смерть» > «рождение»
const rp = uf.find(p);
const rq = uf.find(q);
if (rp === rq) continue;
const nodeP = mapRootToNode[rp], nodeQ = mapRootToNode[rq];
// Закрываем детей на текущем уровне
if (this.hierarchy[nodeP].lambdaDeath == null) this.hierarchy[nodeP].lambdaDeath = level;
if (this.hierarchy[nodeQ].lambdaDeath == null) this.hierarchy[nodeQ].lambdaDeath = level;
// Создаём нового родителя
const parentNodeIndex = this.hierarchy.length;
const newPoints = this.hierarchy[nodeP].points.concat(this.hierarchy[nodeQ].points);
this.hierarchy.push({
clusterId: parentNodeIndex, lambdaBirth: level, lambdaDeath: null,
points: newPoints, size: newPoints.length, children: [nodeP, nodeQ]
});
uf.union(rp, rq); // Union и переназначение представителя
const newRoot = uf.find(rp);
mapRootToNode[newRoot] = parentNodeIndex;
}
for (const node of this.hierarchy) { // Узлы, которые так и не «умерли» (верхний и, возможно, промежуточные, если n=1)
if (node.lambdaDeath == null) node.lambdaDeath = node.lambdaBirth; // стабильность 0
}
}
extractClusters() {
const EPS = 1e-12;
// 1) стабильность только для узлов size >= minClusterSize
const stability = new Map();
const eligible = [];
for (const node of this.hierarchy) {
if (node.size >= this.minClusterSize) {
let s = (node.lambdaDeath - node.lambdaBirth) * node.size;
if (!Number.isFinite(s) || s < EPS) s = 0;
stability.set(node.clusterId, s);
eligible.push(node);
}
}
// 2) кандидаты с положительной стабильностью
const positives = eligible.filter(n => (stability.get(n.clusterId) || 0) > 0);
// 3) активные: либо все положительные, либо (если их нет) глобальный узел (size = n)
const active = new Map(); // clusterId -> { points, size, stab }
if (positives.length > 0) {
for (const n of positives) {
active.set(n.clusterId, { points: n.points, size: n.size, stab: stability.get(n.clusterId) });
}
} else if (eligible.length > 0) {
// берём самый большой (обычно это «все точки»)
let full = eligible[0];
for (const n of eligible) if (n.size > full.size) full = n;
active.set(full.clusterId, { points: full.points, size: full.size, stab: 0 });
}
// 4) порядок: stab ↓, затем size ↓
const order = Array.from(active.entries())
.sort(([, a], [, b]) => (b.stab - a.stab) || (b.size - a.size))
.map(([id]) => id);
// 5) присваиваем метки (первичный проход)
this.labels.fill(-1);
let cid = 1;
const assigned = new Set();
for (const k of order) {
let any = false;
for (const p of active.get(k).points) {
if (!assigned.has(p)) {
this.labels[p] = cid;
assigned.add(p);
any = true;
}
}
if (any) cid++;
}
// 6) FALLBACK: добираем оставшихся с помощью компонент MST
const unassigned = new Set();
for (let i = 0; i < this.k; i++) if (this.labels[i] === -1) unassigned.add(i);
if (unassigned.size > 0) {
const uf = new UnionFind(this.k);
for (const [u, v] of this.mst) { // идем по рёбрам в возрастании веса; по мере объединения проверяем компоненты среди неразмеченных
uf.union(u, v);
const comps = new Map(); // root -> members[]
for (const p of unassigned) { // собираем компоненты только по неразмеченным точкам
const r = uf.find(p);
const arr = comps.get(r);
if (arr) arr.push(p); else comps.set(r, [p]);
}
for (const members of comps.values()) { // если появилась компонента достаточного размера — размечаем её
if (members.length >= this.minClusterSize) {
for (const p of members) {
this.labels[p] = cid;
unassigned.delete(p);
}
cid++;
}
}
if (unassigned.size === 0) break;
}
}
}
}
class UnionFind {
constructor(n) { this.parent = Array.from({ length: n }, (_, i) => i); this.rank = new Array(n).fill(0); }
find(x) { if (this.parent[x] !== x) this.parent[x] = this.find(this.parent[x]); return this.parent[x]; }
union(x, y) {
const rx = this.find(x), ry = this.find(y);
if (rx === ry) return;
if (this.rank[rx] < this.rank[ry]) this.parent[rx] = ry;
else if (this.rank[rx] > this.rank[ry]) this.parent[ry] = rx;
else { this.parent[ry] = rx; this.rank[rx]++; }
}
}