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
JavaScript
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)}`);
});
});