UNPKG

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
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]++; } } }