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

325 lines (280 loc) 13.6 kB
import { describe, it } from 'node:test'; import assert from 'node:assert'; import Hdbscan from '../../../lib/analyze/dbscan/hdbscan.js'; // помощник: создаём таблицу с именованными колонками function makeTable(namedArrays) { const t = {}; for (const [name, arr] of namedArrays) { t[name] = arr } return t; } describe('Hdbscan', () => { it('throws if table has less than 2 columns', () => { const t = makeTable([['A', [1, 2, 3]]]); // assert.throws(() => new Hdbscan(t, { minClusterSize: 2 }), { message: 'Hdbscan requires at least 2 groups' }); assert.throws(() => new Hdbscan(t, { minClusterSize: 2 })); }); it('forms two clusters for two clear pairs (AB) and (CD)', () => { const t = makeTable([ ['A', [1, 2, 3, 4, 5]], ['B', [2, 3, 4, 5, 6]], ['C', [10, 20, 30, 40, 50]], ['D', [11, 21, 31, 41, 51]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); // Две пары должны оказаться в двух кластерах const labels = h.labels; const labA = labels[0], labB = labels[1], labC = labels[2], labD = labels[3]; assert.ok(labA > 0 && labB > 0 && labC > 0 && labD > 0, 'все должны получить кластерные метки'); assert.strictEqual(labA, labB, 'A и B в одном кластере'); assert.strictEqual(labC, labD, 'C и D в одном кластере'); assert.notStrictEqual(labA, labC, 'пары в разных кластерах'); assert.strictEqual(h.clusters.length, 2, 'должно быть 2 кластера-таблицы'); }); it('AB close + C far → текущая реализация даёт 2 кластера (AB и кластер, добравший C)', () => { const t = makeTable([ ['A', [1, 2, 3, 4, 5]], ['B', [2, 3, 4, 5, 6]], ['C', [100, 200, 300, 400, 500]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); const labels = h.labels; // AB вместе, C в другом кластере (особенность текущей реализации) assert.strictEqual(labels[0], labels[1], 'A и B должны быть вместе'); assert.strictEqual(labels[2], -1, 'C должен быть шумом при minClusterSize=2'); assert.notStrictEqual(labels[0], labels[2], 'и он не совпадает с AB'); assert.strictEqual(h.clusters.length, 1, 'ожидаем 1 кластер-таблицу (AB), C — шум'); // assert.strictEqual(h.clusters.length, 2, 'ожидаем 2 кластера-таблицы'); }); it('all identical columns with minClusterSize = n → один общий кластер', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [1, 2, 3]], ['C', [1, 2, 3]], ]); const h = new Hdbscan(t, { minClusterSize: 3 }); const labels = h.labels; // все в одном кластере assert.ok(labels[0] > 0 && labels[1] > 0 && labels[2] > 0); assert.strictEqual(labels[0], labels[1]); assert.strictEqual(labels[1], labels[2]); assert.strictEqual(h.clusters.length, 1); }); it('mreachDistances[i][i] equals core-distance (2nd smallest distance) for minClusterSize=2', () => { const t = makeTable([ ['A', [1, 2, 3, 4]], ['B', [2, 3, 4, 5]], ['C', [10, 20, 30, 40]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); for (let i = 0; i < h.k; i++) { const sorted = h.distances[i].slice().sort((a, b) => a - b); const expectedCore = sorted[2 - 1]; // minClusterSize - 1 assert.ok(Math.abs(h.mreachDistances[i][i] - expectedCore) < 1e-12); } }); it('MST edges are sorted by weight ascending', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [2, 3, 4]], ['C', [3, 4, 5]], ['D', [100, 200, 300]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); const weights = h.mst.map(e => e[2]); for (let i = 1; i < weights.length; i++) { assert.ok(weights[i - 1] <= weights[i], 'MST должен быть отсортирован по весу'); } }); it('handles NaN correlations by treating distance as 1 (no crash)', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [2, 3, 4]], ['C', [3, 4, 5]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); // Проверим, что матрицы заполнены числами for (let i = 0; i < h.n; i++) { for (let j = 0; j < h.n; j++) { assert.ok(Number.isFinite(h.distances[i][j])); assert.ok(Number.isFinite(h.mreachDistances[i][j])); } } // И что хотя бы одна пара (A,B) попала в один кластер const labels = h.labels; assert.strictEqual(labels[0], labels[1]); }); }); describe('Hdbscan — extra coverage', () => { it('minClusterSize > n ⇒ все точки — noise, кластеров нет', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [2, 3, 4]], ['C', [3, 4, 5]], ]); const h = new Hdbscan(t, { minClusterSize: 5 }); assert.ok(h.labels.every(l => l === -1)); assert.strictEqual(h.clusters.length, 0); }); it('все одинаковые при minClusterSize=2 → один общий кластер (tie по стабильности решается размером)', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [1, 2, 3]], ['C', [1, 2, 3]], ['D', [1, 2, 3]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); // ожидаем один кластер на всех const lbl = h.labels; assert.ok(lbl.every(l => l > 0)); const uniq = new Set(lbl); assert.strictEqual(uniq.size, 1); assert.strictEqual(h.clusters.length, 1); }); it('undefined корреляция трактуется как distance=1 (как и NaN) — не падает', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [2, 3, 4]], ['C', [3, 4, 5]], ]); const h = new Hdbscan(t, { minClusterSize: 2 }); // матрицы заполнены числами for (let i = 0; i < h.n; i++) { for (let j = 0; j < h.n; j++) { assert.ok(Number.isFinite(h.distances[i][j])); assert.ok(Number.isFinite(h.mreachDistances[i][j])); } } // A и B должны быть в одном кластере, C — либо шум, либо в другом (в любом случае — не падаем) assert.strictEqual(h.labels[0], h.labels[1]); }); it('minClusterSize=3: диагональ mreach равна 3-му минимуму (ядровая дистанция)', () => { const t = makeTable([ ['A', [1, 2, 3, 4]], ['B', [2, 3, 4, 5]], ['C', [10, 20, 30, 40]], ['D', [11, 21, 31, 41]], ]); // расстояния: A-B ближе всего; C-D ближе всего; остальное далеко const h = new Hdbscan(t, { minClusterSize: 3 }); // Для каждой точки ядровая дистанция — третий минимум в её строке distances // Примерно: у A: [0, 0.1, 1.0, 1.0] -> core=1.0 const expectedCore = [ 1.0, // A 1.0, // B 0.08, // C (dist to D = 0.08, но 3-й минимум с учётом 0 и 0.08 и 1.0 → 1.0… подберём по факту) 0.08, // D ]; // Но проще — пересчитать по самой матрице h.distances: взять 3-й минимум для каждой строки for (let i = 0; i < h.n; i++) { const sorted = h.distances[i].slice().sort((a, b) => a - b); const core = sorted[Math.min(3 - 1, h.n - 1)]; const diag = h.mreachDistances[i][i]; assert.ok(Math.abs(core - diag) < 1e-12, `row ${i}: core=${core}, diag=${diag}`); } }); it('MST имеет ровно n-1 ребро, если граф связный (через mreach)', () => { const t = makeTable([ ['A', [1, 2, 3]], ['B', [2, 3, 4]], ['C', [3, 4, 5]], ['D', [4, 5, 6]], ]); // полный связный граф по mreach (корреляции не нули везде) const h = new Hdbscan(t, { minClusterSize: 2 }); assert.strictEqual(h.mst.length, h.k - 1); }); }); describe('Hdbscan — metric option', () => { it('MAD metric splits into two clusters for AB|CD pairs', () => { const t = makeTable([ ['A', [1, 2, 3, 4, 5]], ['B', [2, 3, 4, 5, 6]], ['C', [10, 20, 30, 40, 50]], ['D', [11, 21, 31, 41, 51]], ]); const h = new Hdbscan(t, { minClusterSize: 2, metric: 'mad' }); const lbl = h.labels; // ожидаем две группы по 2 const a = lbl[0], b = lbl[1], c = lbl[2], d = lbl[3]; assert.ok(a > 0 && b > 0 && c > 0 && d > 0); assert.strictEqual(a, b); assert.strictEqual(c, d); assert.notStrictEqual(a, c); assert.strictEqual(h.clusters.length, 2); }); it('Pearson metric glues AB|CD into 1 cluster (shape-only)', () => { const t = makeTable([ ['A', [1, 2, 3, 4, 5]], ['B', [2, 3, 4, 5, 6]], ['C', [10, 20, 30, 40, 50]], ['D', [11, 21, 31, 41, 51]], ]); const h = new Hdbscan(t, { minClusterSize: 2, metric: 'pearson' }); const uniq = new Set(h.labels.filter(x => x > 0)); // один кластер на всех assert.strictEqual(uniq.size, 1); assert.strictEqual(h.clusters.length, 1); }); }); describe('Hdbscan — canonical lambda (1/(d+eps))', () => { it('child clusters (AB, CD) have positive propagated stability, parent ~ 0', () => { const t = makeTable([ ['A', [1, 2, 3, 4, 5]], ['B', [2, 3, 4, 5, 6]], ['C', [10, 20, 30, 40, 50]], ['D', [11, 21, 31, 41, 51]], ]); const h = new Hdbscan(t, { minClusterSize: 2, metric: 'mad' }); // простой оракул: две метки → два активных кластера на листьях/внутренних узлах const uniq = new Set(h.labels.filter(x => x > 0)); assert.strictEqual(uniq.size, 2); }); }); describe('Hdbscan — edges', () => { it('minClusterSize>n ⇒ all noise', () => { const t = makeTable([['A', [1, 2, 3]], ['B', [2, 3, 4]]]); const h = new Hdbscan(t, { minClusterSize: 3, metric: 'mad' }); assert.ok(h.labels.every(l => l === -1)); assert.strictEqual(h.clusters.length, 0); }); it('mreach diag equals k-th minimum for k=2 and k=3', () => { const t = makeTable([ ['A', [1, 2, 3, 4]], ['B', [2, 3, 4, 5]], ['C', [10, 20, 30, 40]], ['D', [11, 21, 31, 41]], ]); const h2 = new Hdbscan(t, { minClusterSize: 2, metric: 'mad' }); for (let i = 0; i < h2.n; i++) { const sorted = h2.distances[i].slice().sort((a, b) => a - b); const expected = sorted[2 - 1]; assert.ok(Math.abs(h2.mreachDistances[i][i] - expected) < 1e-12); } const h3 = new Hdbscan(t, { minClusterSize: 3, metric: 'mad' }); for (let i = 0; i < h3.n; i++) { const sorted = h3.distances[i].slice().sort((a, b) => a - b); const expected = sorted[3 - 1]; assert.ok(Math.abs(h3.mreachDistances[i][i] - expected) < 1e-12); } }); it('HDBSCAN: разметка через FALLBACK (компоненты MST)', () => { // Две плотные пары далеко друг от друга → MST: (A-B) и (C-D) короткие, потом «длинное» ребро const data = { A: [0,0,0,0], B: [0,0,0,0], C: [100,100,100,100], D: [100,100,100,100], }; const h = new Hdbscan(data, { minClusterSize: 2, metric: 'mad' }); // Подменяем иерархию на список синглтонов (size=1), чтобы активные кластеры не выбрались, // а отработал именно блок FALLBACK (строки 179–184 в твоём отчёте). h.hierarchy = Array.from({ length: h.k }, (_, i) => ({ clusterId: i, lambdaBirth: 0, lambdaDeath: 0, points: [i], size: 1, children: [] })); h.labels.fill(-1); h.extractClusters(); // прогоняем оригинальный код с FALLBACK // Должны появиться хотя бы метки на компонентах размером ≥ minClusterSize (у нас это пары). const labeled = h.labels.filter(x => x !== -1); assert.ok(labeled.length >= 2, `Ожидались размеченные узлы, получили labels=${JSON.stringify(h.labels)}`); }); });