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

486 lines (400 loc) 17.7 kB
import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; import Column from '../../lib/column/index.js'; import slopeByIndex from '../../lib/descriptive/slope-by-index.js'; import Stats from '../../lib/descriptive/index.js'; function isNonDecreasing(arr) { for (let i = 1; i < arr.length; i++) if (arr[i - 1] > arr[i]) return false; return true; } function approx(actual, expected, eps = 1e-12) { assert.ok(Math.abs(actual - expected) <= eps, `expected ~${expected}, got ${actual}`); } describe('Column class', () => { let column; beforeEach(() => { column = new Column([1, 2, 3, 4, 5]); }); it('should initialize correctly', () => { assert.deepStrictEqual(column.values, [1, 2, 3, 4, 5]); }); it('should compute correct n (length of values)', () => { assert.strictEqual(column.n, 5); }); it('should return min for percentile 0', () => { const column = new Column([5, 2, 8, 1, 9]); assert.strictEqual(column.percentile(0), 1); }); it('should return max for percentile 100', () => { const column = new Column([5, 2, 8, 1, 9]); assert.strictEqual(column.percentile(100), 9); }); // it('recode method', () => { // const column = new Column([10, 20, 30]); // const newCol = column.recode(v => v/10) // assert(newCol !== column) // assert.deepStrictEqual(newCol.values,[1,2,3]) // }) }); describe('Column class (nominal-like)', () => { it('should create a column with valid string values', () => { const labels = ['A', 'B', 'A', 'C', 'B', 'A'] const column = new Column(undefined,undefined,labels); assert.deepStrictEqual(column.values, [0,1,2,3,4,5]); assert.strictEqual(column.n, 6); }); }); describe('Column class (ordinal-like)', () => { it('should create a column with valid values', () => { const column = new Column([3, 1, 4, 2]); assert.deepStrictEqual(column.values, [3, 1, 4, 2]); }); it('should sort numeric values correctly', () => { const column = new Column([3, 1, 4, 2]); assert.deepStrictEqual(column.sorted, [1, 2, 3, 4]); }); it('should calculate percentile correctly', () => { const column = new Column([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); assert.strictEqual(column.percentile(50), 55); assert.strictEqual(column.percentile(25), 32.5); assert.strictEqual(column.percentile(75), 77.5); }); it('should return correct median', () => { const column = new Column([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); assert.strictEqual(column.median, 55); }); it('should return correct quartiles', () => { const column = new Column([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); assert.strictEqual(column.q1, 32.5); assert.strictEqual(column.q3, 77.5); }); }); describe('Column', () => { it('should create a ratio column with valid numeric values', () => { const column = new Column([10, 20, 30, 40, 50]); assert.deepStrictEqual(column.values, [10, 20, 30, 40, 50]); }); it('should compute correct sum', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.sum, 150); }); it('should compute correct mean', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.mean, 30); }); it('should return correct min and max values', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.min, 10); assert.strictEqual(column.max, 50); }); it('should compute correct range', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.range, 40); }); it('should compute variance and standard deviation (sample)', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.varianceSample.toFixed(2), '250.00'); assert.strictEqual(column.stdDevSample.toFixed(2), '15.81'); }); it('should compute variance and standard deviation (population)', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.variance.toFixed(2), '200.00'); assert.strictEqual(column.stdDev.toFixed(2), '14.14'); }); it('should compute correct z-scores', () => { const column = new Column([10, 20, 30, 40, 50]); assert.deepStrictEqual(column.zScores.map(v => v.toFixed(2)), [ '-1.41', '-0.71', '0.00', '0.71', '1.41' ]); }); it('should return correct quartiles', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.q1, 20); assert.strictEqual(column.q3, 40); assert.strictEqual(column.iqr, 20); }); it('should return correct median', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.median, 30); }); it('should compute mode correctly', () => { const column1 = new Column([1, 2, 2, 3, 4]); assert.deepStrictEqual(column1.mode, [2]); const column2 = new Column([1, 2, 3, 4, 5]); assert.deepStrictEqual(column2.mode, []); }); it('should compute geometric mean', () => { const column = new Column([1, 3, 9, 27, 81]); assert.strictEqual(column.geometricMean.toFixed(2), '9.00'); }); it('should compute harmonic mean', () => { const column = new Column([1, 2, 3, 4, 5]); assert.strictEqual(column.harmonicMean.toFixed(2), '2.19'); }); it('should compute skewness and kurtosis (population and sample)', () => { const column = new Column([10, 20, 30, 40, 50, 100]); assert.strictEqual(column.skewness.toFixed(2), '1.05'); assert.strictEqual(column.kurtosis.toFixed(2), '-0.02'); assert.strictEqual(column.skewnessSample.toFixed(2), '1.44'); assert.strictEqual(column.kurtosisSample.toFixed(2), '2.44'); }); it('should compute coefficient of variation (CV)', () => { const column = new Column([10, 20, 30, 40, 50]); assert.strictEqual(column.cv.toFixed(2), '0.47'); }); it('should detect outliers using z-score', () => { const column = new Column([10, 20, 30, 40, 50, 100]); const outliers = column.outliersZScore(2); assert.deepStrictEqual(outliers.values, [100]); assert.deepStrictEqual(outliers.indexes, [5]); assert.deepStrictEqual(outliers.zScores.map(v => v.toFixed(2)), ['2.00']); }); it('should detect outliers using IQR method', () => { const column = new Column([10, 20, 30, 40, 50, 100]); assert.deepStrictEqual(column.outliersIQR, [100]); }); it('should compute confidence interval 95%', () => { const column = new Column([10, 20, 30, 40, 50]); const ci = column.confidenceInterval; assert.ok(ci.low < ci.high); assert.ok(ci.width > 0); }); it('should compute weighted mean correctly', () => { const column = new Column([10, 20, 30, 40, 50]); const weights = [1, 2, 3, 4, 5]; assert.strictEqual(column.weightedMean(weights).toFixed(2), '36.67'); }); it('should compute noise stability', () => { const column = new Column([10, 20, 30, 40, 50, 100]); assert.ok(column.noiseStability > 0); }); it('should compute spectral power density metrics', () => { const column = new Column([10, 20, 30, 40, 50, 100]); assert.strictEqual(column.spectralPowerDensityArray.length, column.n); assert.ok(column.spectralPowerDensityMetric > 0); }); it('should correctly normalize values using Min-Max scaling', () => { const column = new Column([10, 20, 30, 40, 50, 100]); const expectedNormalized = [ (10 - 10) / (100 - 10), (20 - 10) / (100 - 10), (30 - 10) / (100 - 10), (40 - 10) / (100 - 10), (50 - 10) / (100 - 10), (100 - 10) / (100 - 10) ]; assert.deepStrictEqual(column.normalizedValues.map(v => v.toFixed(2)), expectedNormalized.map(v => v.toFixed(2))); }); it('should return an array of zeros if all values are the same', () => { const column = new Column([5, 5, 5, 5, 5]); assert.deepStrictEqual(column.normalizedValues, [0, 0, 0, 0, 0]); }); it('should return 0 for standard deviation if all values are identical', () => { const column = new Column([5, 5, 5, 5, 5]); assert.strictEqual(column.stdDev, 0); }); it('should return NaN for harmonic mean if any value is non-positive', () => { const column = new Column([5, -10, 15]); assert(Number.isNaN(column.harmonicMean)); }); it('should compute flatness correctly', () => { const column = new Column([1, 3, 9, 27, 81]); assert.strictEqual(column.flatness.toFixed(2), '0.37'); // geometricMean / mean ≈ 9 / 24.2 }); it('should compute spectral power density metric', () => { const column = new Column([10, 20, 30]); const spdMetric = column.spectralPowerDensityMetric; assert.ok(spdMetric > 0 && spdMetric < 1); // Ожидаем значение между 0 и 1 }); it('should compute noise stability with single value', () => { const column = new Column([5]); assert.deepStrictEqual(column.noiseStability, 0); }); it('should return zeros for normalized values with zero range', () => { const column = new Column([5, 5, 5]); assert.deepStrictEqual(column.normalizedValues, [0, 0, 0]); }); it('should detect no outliers with IQR', () => { const column = new Column([1, 2, 3, 4, 5]); assert.deepStrictEqual(column.outliersIQR, []); }); }); describe('xValues', () => { it('xValues returns [1, 2, ..., n]', () => { const col = new Column([5, 10, 15, 20]); assert.deepStrictEqual(col.xValues, [1, 2, 3, 4]); }); it('xValues is cached (same reference)', () => { const col = new Column([1, 2, 3]); const first = col.xValues; const second = col.xValues; assert.strictEqual(first, second); }); }); describe('regressionSlope', () => { it('regressionSlope() matches perfect slope with default xValues', () => { const col = new Column([10, 20, 30, 40]); // perfect slope 10 const slope = col.regressionSlope(); // X: [1,2,3,4] assert.ok(Math.abs(slope - 10) < 1e-6); }); it('regressionSlope() works with customX', () => { const col = new Column([3, 6, 9]); const slope = col.regressionSlope([1, 2, 3]); assert.ok(Math.abs(slope - 3) < 1e-6); }); it('regressionSlope() returns 0 for constant X', () => { const col = new Column([10, 20, 30]); const slope = col.regressionSlope([5, 5, 5]); assert.strictEqual(slope, 0); }); it('regressionSlope() throws if customX length mismatch', () => { const col = new Column([1, 2, 3]); assert.throws(() => { col.regressionSlope([1, 2]); // wrong length }, /same length/i); }); it('regressionSlope() throws if customX is not an array', () => { const col = new Column([1, 2, 3]); assert.throws(() => { col.regressionSlope("123"); }, /array/i); }); }) describe('slopeByIndex', () => { it('computes slope for increasing linear series', () => { // (8 - 2) / (3 - 0) = 6/3 = 2 assert.strictEqual(slopeByIndex([2, 4, 6, 8]), 2); }); it('computes slope for decreasing linear series', () => { // (1 - 10) / 3 = -9/3 = -3 assert.strictEqual(slopeByIndex([10, 7, 4, 1]), -3); }); it('returns 0 for constant series', () => { // (5 - 5) / 2 = 0 assert.strictEqual(slopeByIndex([5, 5, 5]), 0); }); it('uses only endpoints (middle values do not affect)', () => { // (0 - 0) / 2 = 0, несмотря на большой пик посередине assert.strictEqual(slopeByIndex([0, 100, 0]), 0); }); it('handles decimals with precision', () => { // (4.5 - 1.5) / 3 = 1 approx(slopeByIndex([1.5, 2.0, 3.0, 4.5]), 1); }); it('returns NaN for single-element array (0/0)', () => { const v = slopeByIndex([42]); assert(v === 0) // assert.ok(Number.isNaN(v)); }); it('returns NaN for empty array', () => { const v = slopeByIndex([]); assert(v === 0) // assert.ok(Number.isNaN(v)); }); it('returns NaN when endpoints are non-numeric', () => { assert.ok(Number.isNaN(slopeByIndex(['a', 2]))); assert.ok(Number.isNaN(slopeByIndex([1, 'x']))); }); it('does not mutate the input array', () => { const arr = [1, 2, 3]; const snapshot = arr.slice(); slopeByIndex(arr); assert.deepStrictEqual(arr, snapshot); }); }); describe('Stats.zScoresSorted', () => { it('computes zScores when not provided and returns them sorted with matching indexes', () => { const values = [10, 20, 30, 40]; const { zScores, indexes } = Stats.zScoresSorted({ values }); // длина совпадает assert.strictEqual(zScores.length, values.length); assert.strictEqual(indexes.length, values.length); // zScores по возрастанию assert.ok(isNonDecreasing(zScores), 'zScores must be non-decreasing'); // индексы — перестановка 0..n-1 const sortedIdx = [...indexes].sort((a, b) => a - b); assert.deepStrictEqual(sortedIdx, [0, 1, 2, 3]); // Column.getter zScoresSorted даёт ту же структуру const col = new Column(values, 'V'); const { zScores: z2, indexes: i2 } = col.zScoresSorted; assert.strictEqual(z2.length, zScores.length); assert.strictEqual(i2.length, indexes.length); assert.ok(isNonDecreasing(z2)); assert.deepStrictEqual([...i2].sort((a, b) => a - b), [0, 1, 2, 3]); }); it('respects provided zScores and ignores values for sorting', () => { const zs = [1, 0, -1, 0.5]; // «values» тут произвольные — функция должна ориентироваться на zs const { zScores, indexes } = Stats.zScoresSorted({ zScores: zs, values: [100, 200, 300, 400] }); // Должно отсортировать: -1, 0, 0.5, 1 assert.deepStrictEqual(zScores, [-1, 0, 0.5, 1]); // Индексы: исходные позиции этих значений в zs → [2, 1, 3, 0] assert.deepStrictEqual(indexes, [2, 1, 3, 0]); }); }); describe('Stats.mad (Median Absolute Deviation)', () => { it('returns 0 for empty values', () => { const m = Stats.mad({ sorted: [], median: undefined, values: [] }); assert.strictEqual(m, 0); }); it('works on odd-sized sample (median = middle element)', () => { const values = [1, 2, 3, 4, 100]; // median = 3 const sorted = [...values].sort((a, b) => a - b); const median = sorted[2]; // deviations: [2,1,0,1,97] -> sorted -> [0,1,1,2,97] -> mad = 1 const m1 = Stats.mad({ sorted, median, values }); assert.strictEqual(m1, 1); // Через Column.getter mad — должно совпасть const col = new Column(values, 'V'); assert.strictEqual(col.mad, 1); }); it('works on even-sized sample (median is average of two middles)', () => { const values = [1, 1, 2, 2]; // median = 1.5 const sorted = [...values].sort((a, b) => a - b); const median = (sorted[1] + sorted[2]) / 2; // deviations: [0.5,0.5,0.5,0.5] -> median deviation = 0.5 const m = Stats.mad({ sorted, median, values }); assert.strictEqual(m, 0.5); const col = new Column(values, 'V'); assert.strictEqual(col.mad, 0.5); }); }); describe('Stats.range', () => { it('computes max-min when only values are given', () => { const values = [1, 4, 10]; const r = Stats.range({ values }); assert.strictEqual(r, 9); }); it('uses provided min/max if available (skips recomputation)', () => { // Здесь min/max заданы — функция обязана вернуть max - min const r = Stats.range({ min: 5, max: 9, values: [1, 2] }); assert.strictEqual(r, 4); }); it('accepts pre-sorted data and computes via Stats.min/Stats.max', () => { const sorted = [2, 3, 10]; const values = sorted.slice(); // имитируем уже отсортированные данные const r = Stats.range({ sorted, values }); assert.strictEqual(r, 8); }); }); describe('Column.clone', () => { it('копия независима от оригинала', () => { const c1 = new Column([1,2,3], 'age'); const c2 = c1.clone('age_copy'); c1.addValue(4); assert.deepStrictEqual(c1.values, [1,2,3,4]); assert.deepStrictEqual(c2.values, [1,2,3]); // не изменился }); it('кэш не переносится по умолчанию', () => { const SUM = 'sum'; const c1 = new Column([1,2,3], 'age'); let calls = 0; const sum = col => { calls++; return col.values.reduce((a,b)=>a+b,0) }; assert.strictEqual(c1.$(SUM, sum), 6); assert.strictEqual(calls, 1); const c2 = c1.clone('age_copy'); // withCache=false calls = 0; assert.strictEqual(c2.$(SUM, sum), 6); assert.strictEqual(calls, 1); // посчитали впервые для копии }); });