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
JavaScript
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); // посчитали впервые для копии
});
});