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

138 lines (120 loc) 5.44 kB
import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Analyze } from '../../../lib/index.js'; const { Clustering } = Analyze; const { Dbscan, Hdbscan } = Clustering; // Robust clustering tests for ALS Statistics // // Focus: shape invariants, clear synthetic clusters, determinism. // We avoid exact distance values (implementation-specific), and assert // relative structure (same-series cluster, far-series separated, noise handled). // ---------- helpers ---------- function labelsByName(names, labels) { // Map label -> [column names], skip noise (-1) and unvisited (0) const map = new Map(); names.forEach((name, i) => { const lab = labels[i]; if (lab > 0) { if (!map.has(lab)) map.set(lab, []); map.get(lab).push(name); } }); // Normalize groups (sorted names), but keep separate cluster-ids return [...map.entries()].map(([id, cols]) => ({ id, cols: cols.sort() })) .sort((a, b) => a.cols[0].localeCompare(b.cols[0])); } function noiseNames(names, labels) { return names.filter((_, i) => labels[i] === -1).sort(); } function sameSets(a, b) { if (a.length !== b.length) return false; const A = a.map(g => g.cols.join('|')).sort(); const B = b.map(g => g.cols.join('|')).sort(); return A.every((v, i) => v === B[i]); } // ---------- synthetic data ---------- // Two tight clusters of IDENTICAL series + one outlier/noise. // Using identical series makes MAD-based distance within-cluster == 0. const data = { A1: [0, 0, 0, 0, 0], A2: [0, 0, 0, 0, 0], B1: [10, 10, 10, 10, 10], B2: [10, 10, 10, 10, 10], N1: [50, -50, 50, -50, 50], // noisy series (far from both) }; const names = Object.keys(data); describe('Clustering', () => { describe('DBSCAN — two clusters + noise', () => { it.skip('finds {A1,A2} and {B1,B2} as clusters; N1 is noise', () => { const eps = 1; // within-cluster distance is 0 → connected const minPts = 2; // need pairs to form clusters const db = new Dbscan(data, { eps, minPts, metric: 'mad' }); // Basic invariants for distances assert.ok(Array.isArray(db.distances) && db.distances.length === names.length, 'distance matrix shape'); for (let i = 0; i < names.length; i++) { assert.equal(db.distances[i][i], 0, 'diag is zero'); for (let j = 0; j < names.length; j++) { assert.ok(db.distances[i][j] >= 0, 'non-negative distance'); assert.equal(db.distances[i][j], db.distances[j][i], 'symmetry'); } } const groups = labelsByName(names, db.labels); const noise = noiseNames(names, db.labels); // Expect exactly 2 clusters of size 2 each assert.equal(groups.length, 2, 'number of clusters'); // Cluster *composition* (ids may differ) const expected = [ { cols: ['A1', 'A2'] }, { cols: ['B1', 'B2'] }, ]; assert.ok( sameSets(groups, expected.map(x => ({ id: 1, cols: x.cols }))), `clusters mismatch: got ${JSON.stringify(groups)}` ); // Expect N1 as noise assert.deepEqual(noise, ['N1']); }); it.skip('no clusters when minPts too high (all noise)', () => { const db = new Dbscan(data, { eps: 1, minPts: 3, metric: 'mad' }); const groups = labelsByName(names, db.labels); const noise = noiseNames(names, db.labels); assert.equal(groups.length, 0, 'no clusters at minPts=3 with pairs only'); assert.deepEqual(noise.sort(), names.slice().sort(), 'everyone becomes noise'); }); it('deterministic results (same inputs → same grouping)', () => { const a = new Dbscan(data, { eps: 1, minPts: 2, metric: 'mad' }); const b = new Dbscan(data, { eps: 1, minPts: 2, metric: 'mad' }); const ga = labelsByName(names, a.labels); const gb = labelsByName(names, b.labels); assert.ok(sameSets(ga, gb), 'deterministic cluster composition'); assert.deepEqual(noiseNames(names, a.labels), noiseNames(names, b.labels), 'deterministic noise set'); }); }); describe('HDBSCAN — two clusters + noise', () => { it.skip('finds stable clusters for A and B; N1 as noise (with minClusterSize=2)', () => { const hdb = new Hdbscan(data, { metric: 'mad', minClusterSize: 2 }); // Labels shape assert.equal(hdb.labels.length, names.length, 'labels length'); const groups = labelsByName(names, hdb.labels); const noise = noiseNames(names, hdb.labels); // Two clusters expected (composition-focused) const expected = [ { cols: ['A1', 'A2'] }, { cols: ['B1', 'B2'] }, ]; assert.ok( sameSets(groups, expected.map(x => ({ id: 1, cols: x.cols }))), `clusters mismatch: got ${JSON.stringify(groups)}` ); // Noise contains N1 assert.deepEqual(noise, ['N1']); }); it('deterministic hierarchy → deterministic final labels', () => { const h1 = new Hdbscan(data, { metric: 'mad', minClusterSize: 2 }); const h2 = new Hdbscan(data, { metric: 'mad', minClusterSize: 2 }); const g1 = labelsByName(names, h1.labels); const g2 = labelsByName(names, h2.labels); assert.ok(sameSets(g1, g2), 'deterministic HDBSCAN composition'); }); }); });