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

248 lines (214 loc) 10.1 kB
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 — это только диагностика }); }); }); }