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
248 lines (214 loc) • 10.1 kB
JavaScript
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EPS } from "../settings.js";
import { nearlyEqual, hasDataOrSkip } from '../helpers.js';
import { Analyze } from '../../lib/index.js';
const { Clustering } = Analyze;
const { Dbscan, Hdbscan } = Clustering;
/** Группы (массив массивов имён) из name->label; игнорируем шум. */
function groupsFromLabels(order, labelByName, noiseIsNeg) {
const buckets = new Map();
for (const name of order) {
const lab = Number(labelByName[name]);
const isNoise = noiseIsNeg ? lab < 0 : lab < 1;
if (isNoise) continue;
const key = String(lab);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(name);
}
return [...buckets.values()]
.map(cols => cols.slice().sort())
.sort((a, b) => a[0].localeCompare(b[0]));
}
/** Множество имён, отмеченных как noise. */
function noiseSet(order, labelByName, noiseIsNeg) {
const out = [];
for (const nm of order) {
const lab = Number(labelByName[nm]);
const isNoise = noiseIsNeg ? lab < 0 : lab < 1;
if (isNoise) out.push(nm);
}
return out.sort();
}
/** Кластеры библиотеки: массив массивов имён (label>0), без шума. */
function libClusters(order, labelByName) {
const pos = new Map();
for (const nm of order) {
const lab = Number(labelByName[nm]); // ALS: noise=-1, кластеры 1..N
if (lab > 0) {
const key = String(lab);
if (!pos.has(key)) pos.set(key, []);
pos.get(key).push(nm);
}
}
return [...pos.values()].map(a => a.slice().sort());
}
// --- замените noCrossMixing на универсальную проверку совместимости разбиений:
function isSubset(a, b) {
if (a.length > b.length) return false;
return a.every(x => b.includes(x));
}
function intersect(a, b) {
const sb = new Set(b);
return a.filter(x => sb.has(x));
}
/**
* Разбиения совместимы, если для КАЖДОЙ пары кластеров (a из A, b из B)
* их пересечение либо пусто, либо одно множество является подмножеством другого.
* Это допускает split (lib ⊆ golden) ИЛИ merge (golden ⊆ lib), но запрещает
* «перекрёстные» пересечения.
*/
function partitionsAreCompatible(A, B) {
for (const a of A) {
for (const b of B) {
const inter = intersect(a, b);
if (inter.length === 0) continue;
if (!(isSubset(a, b) || isSubset(b, a))) return false;
}
}
return true;
}
/** Подматрица расстояний по именам из `order`. */
function submatrixByOrder(dist, libNames, order) {
const idx = Object.fromEntries(libNames.map((n, i) => [n, i]));
const ids = order.map(n => idx[n]).filter(i => i != null);
return ids.map(i => ids.map(j => dist[i][j]));
}
export function testClustering(g) {
const { data, clustering } = g;
hasDataOrSkip(g);
const {
distance_metric,
col_order = Object.keys(data),
dbscan,
hdbscan
} = clustering;
const order = Array.isArray(col_order) ? col_order : Object.keys(data);
describe('Clustering', () => {
describe('DBSCAN', () => {
const { eps, minPts, labels: goldenLabels } = dbscan;
it('no cross-mixing vs golden; noise matches golden', () => {
const db = new Dbscan(data, { eps, minPts, metric: distance_metric });
const libNames = Object.keys(data);
const libLabelByName = {};
for (let i = 0; i < libNames.length; i++) libLabelByName[libNames[i]] = db.labels[i];
const goldenGroups = groupsFromLabels(order, goldenLabels, /*golden noise<0*/ true);
const libGroups = libClusters(order, libLabelByName);
// Разрешаем split/merge, но запрещаем перемешивание между golden-блоками:
assert.ok(
partitionsAreCompatible(libGroups, goldenGroups),
`DBSCAN mixed items from different golden clusters.
golden: ${JSON.stringify(goldenGroups)}
lib: ${JSON.stringify(libGroups)}`
);
// Шум — строго как в golden:
const libNoise = noiseSet(order, libLabelByName, /*ALS noise<1*/ false);
const goldenNoise = noiseSet(order, goldenLabels, true);
assert.deepStrictEqual(
libNoise, goldenNoise,
`DBSCAN noise differs.\nexpected: ${JSON.stringify(goldenNoise)}\nactual: ${JSON.stringify(libNoise)}`
);
});
it('distance invariants on submatrix(order): square, symmetric, zeros on diag', () => {
const db = new Dbscan(data, { eps, minPts, metric: distance_metric });
const libNames = Object.keys(data);
const sub = submatrixByOrder(db.distances, libNames, order);
const n = order.length;
assert.equal(sub.length, n, 'submatrix rows == order length');
for (let i = 0; i < n; i++) {
assert.equal(sub[i].length, n, 'submatrix cols == order length');
nearlyEqual(sub[i][i], 0, EPS.stat, 0, `D[${i},${i}]`);
for (let j = i + 1; j < n; j++) {
nearlyEqual(sub[i][j], sub[j][i], 1e-9, 0, `symmetry D[${i},${j}]`);
assert.ok(sub[i][j] >= 0, 'non-negativity');
}
}
});
});
describe('HDBSCAN', () => {
const {
min_cluster_size,
labels: goldenLabels,
noise: goldenNoise = []
} = hdbscan;
it('no cross-mixing vs golden; noise matches golden', () => {
const hdb = new Hdbscan(data, { metric: distance_metric, minClusterSize: min_cluster_size });
const libNames = Object.keys(data);
const libLabelByName = {};
for (let i = 0; i < libNames.length; i++) libLabelByName[libNames[i]] = hdb.labels[i];
const goldenGroups = groupsFromLabels(order, goldenLabels, /*golden noise<0*/ true);
const libGroups = libClusters(order, libLabelByName);
assert.ok(
partitionsAreCompatible(libGroups, goldenGroups),
`HDBSCAN mixed items from different golden clusters.
golden: ${JSON.stringify(goldenGroups)}
lib: ${JSON.stringify(libGroups)}`
);
const libNoise = noiseSet(order, libLabelByName, /*ALS noise<1*/ false);
const goldenNoiseS = goldenNoise.slice().sort();
assert.deepStrictEqual(
libNoise, goldenNoiseS,
`HDBSCAN noise differs.\nexpected: ${JSON.stringify(goldenNoiseS)}\nactual: ${JSON.stringify(libNoise)}`
);
});
// Прежний тест на "не больше кластеров" удалён: и split, и merge допустимы,
// пока нет перемешивания и совпадает шум.
it('Soft metrics: ARI/NMI diagnostics (no fail on low score)', () => {
// Собираем метки библиотеки так же, как в первом тесте
const hdb = new Hdbscan(data, { metric: distance_metric, minClusterSize: min_cluster_size });
const libNames = Object.keys(data);
const libLabelByName = {};
for (let i = 0; i < libNames.length; i++) libLabelByName[libNames[i]] = hdb.labels[i];
// Берём имена колонок в согласованном порядке
const names = order.slice();
// Golden: name -> label уже есть в переменной goldenLabels
const gl = names.map(n => Number(goldenLabels[n] ?? -1));
const lb = names.map(n => Number(libLabelByName[n] ?? -1));
// Построим матрицу сопряжённости (контингенции)
function contingency(labelsA, labelsB) {
const mapA = new Map(), mapB = new Map();
const A = [], B = [];
for (let i = 0; i < labelsA.length; i++) {
const a = labelsA[i], b = labelsB[i];
if (!mapA.has(a)) mapA.set(a, mapA.size);
if (!mapB.has(b)) mapB.set(b, mapB.size);
A.push(mapA.get(a)); B.push(mapB.get(b));
}
const K = mapA.size, L = mapB.size;
const M = Array.from({ length: K }, () => Array(L).fill(0));
for (let i = 0; i < A.length; i++) M[A[i]][B[i]]++;
return M;
}
const M = contingency(gl, lb);
const n = gl.length;
const sum = arr => arr.reduce((s, x) => s + x, 0);
const comb2 = x => (x < 2 ? 0 : x * (x - 1) / 2);
// ARI
const a = M.map(row => comb2(sum(row))).reduce((s, x) => s + x, 0);
const b = M[0].map((_, j) => comb2(sum(M.map(r => r[j])))).reduce((s, x) => s + x, 0);
const c = M.reduce((s, row) => s + row.reduce((s2, x) => s2 + comb2(x), 0), 0);
const t = comb2(n);
const ari = (c - (a * b) / t) / (0.5 * (a + b) - (a * b) / t || 1);
// NMI
const eps = 1e-12;
const rowSums = M.map(row => sum(row));
const colSums = M[0].map((_, j) => sum(M.map(r => r[j])));
let mi = 0;
for (let i = 0; i < M.length; i++) {
for (let j = 0; j < M[i].length; j++) {
const nij = M[i][j];
if (!nij) continue;
mi += (nij / n) * Math.log((nij * n + eps) / (rowSums[i] * colSums[j] + eps));
}
}
const H = counts => {
const N = sum(counts);
return counts.reduce((h, c) => h - (c ? (c / N) * Math.log(c / N) : 0), 0);
};
const nmi = mi / ((H(rowSums) + H(colSums)) / 2 || 1);
console.log(`HDBSCAN diagnostics: ARI=${ari.toFixed(3)} NMI=${nmi.toFixed(3)}`);
// ВАЖНО: никаких assert — это только диагностика
});
});
});
}