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