als-statistics
Version:
A powerful and lightweight JavaScript library for descriptive statistics, regression, clustering, outlier detection, and noise analysis using a flexible table/column architecture.
33 lines (31 loc) • 44.5 kB
JavaScript
const Statistics = (function(){
class ColumnFilter {
constructor() {
this.filterState = 0;
this.init()
}
init() {
this.filtered = {};
this.maxFilter = 0;
this.count = 0
}
get isFiltered() { return this.count > 0 }
filterRows(indexes = []) {
const { count } = this
for (let i of indexes) {
if (this.filtered[i]) continue
this.filtered[i] = true
this.count++
}
if (indexes[indexes.length - 1] > this.maxFilter) this.maxFilter = indexes[indexes.length - 1]
if (this.count > count) this.filterState++
return this
}
clearRowsFilters(indexes = []) {
if (this.maxFilter === 0) return
const { count, maxFilter } = this
for (let i of indexes) {
if (!this.filtered[i]) continue
delete this.filtered[i]
this.count--
}
if (!this.filtered[maxFilter]) { // If `maxFilter` deleted, find new max
const keys = Object.keys(this.filtered).map(n => Number(n))
this.maxFilter = keys[keys.length - 1] || 0;
}
if (count > this.count) this.filterState++
return this
}
clearAllRowsFilters() { this.init(); this.filterState++; return this }
}
function compute(fn, { n, columns }) {
const values = [];
let j = 0;
for (let i = 0; i < n; i++) {
const obj = {}
for (let colName in columns) {
obj[colName] = columns[colName]._values[i]
}
values.push(fn(obj, j))
j++
}
return values
}
function getType(values=[]) {
if (!Array.isArray(values)) throw new Error("Input must be an array");
let type = Number;
for(let v of values) {
if(typeof v === 'number') {
if (!Number.isFinite(v) || isNaN(v)) throw new Error("All elements in the array must be finite valid numbers");
continue
}
type = String
break;
}
return type
}
class BaseColumn {
cache = {};
constructor(values=[], columnFilter, type) {
if (!Array.isArray(values)) throw new Error("Input must be a non-empty array");
this.columnFilter = columnFilter || new ColumnFilter()
this.type = type || getType(values)
this._values = values;
}
count(fn,filtered=true) { return (filtered ? this.values : this._values).filter(fn) }
cached(name, fn) {
const state = this.columnFilter.filterState;
if (this.cache[name] && this.cache[name].state === state) return this.cache[name].value;
this.cache[name] = { state, value: fn() };
return this.cache[name].value;
}
filterRows(indexes) { this.columnFilter.filterRows(indexes) }
clearRowsFilters(indexes) { this.columnFilter.clearRowsFilters(indexes) }
clearAllRowsFilters() { this.columnFilter.clearAllRowsFilters() }
filterRowsBy(fn) {
const indexes = this._values.map((v, i) => fn(v, i) ? i : false).filter(v => v !== false)
this.filterRows(indexes)
}
get values() {
return this.cached('values', () => {
if (!this.columnFilter.isFiltered) return this._values
return this._values.filter((v, i) => !this.columnFilter.filtered[i])
})
}
get n() { return this.values.length }
}
class Column extends BaseColumn {
constructor(values, columnFilter) {
super(values, columnFilter)
}
clone(filtered=true,table) { return new this.constructor(filtered ? this.values : this._values,table) }
get frequencies() { // Absolute frequencies for each unique value
return this.cached('absoluteFrequencies', () => {
const freq = new Map();
for (const v of this.values) { freq.set(v, (freq.get(v) || 0) + 1) }
return Object.fromEntries(freq);
});
}
get relativeFrequencies() { // Relative frequencies (the proportion of each value relative to the total)
return this.cached('relativeFrequencies', () => {
const { frequencies, n } = this, relFreq = {};
for (const key in frequencies) { relFreq[key] = frequencies[key] / n }
return relFreq;
});
}
get sorted() {
return this.cached('sorted', () => {
const valuesCopy = this.values.slice();
if (valuesCopy.length === 0) return valuesCopy;
if (this.type === Number) return valuesCopy.sort((a, b) => a - b);
return valuesCopy.sort((a, b) => String(a).localeCompare(String(b)));
});
}
percentile(p) {
const { sorted, n } = this
if (p < 0 || p > 100) throw new Error("Percentile must be between 0 and 100");
if (p === 0) return sorted[0];
if (p === 100) return sorted[n - 1];
const index = Math.floor((p / 100) * (n - 1)); // Берем нижний индекс
return sorted[index]; // Возвращаем число без усреднения
}
get median() { return this.cached('median', () => this.percentile(50)) }
get q1() { return this.cached('q1', () => this.percentile(25)) }
get q3() { return this.cached('q3', () => this.percentile(75)) }
}
class MovingAverage {
constructor(sample, windowSize,sampleThreshold = 1000) {
this.sample = sample
this.windowSize = windowSize
if (!Number.isInteger(windowSize) || windowSize < 1) throw new Error("windowSize must be a positive integer");
if (windowSize > sample.n) throw new Error('window size cant be bigger than sample size')
this.result = []
sample.n > sampleThreshold ? this.bigSample() : this.naiveSample()
}
bigSample() {
const { sample: { values, n }, windowSize,result } = this
const prefixSums = new Array(n + 1).fill(0);
for (let i = 0; i < n; i++) { prefixSums[i + 1] = prefixSums[i] + values[i] }
for (let i = 0; i + windowSize <= n; i++) {
result.push((prefixSums[i + windowSize] - prefixSums[i]) / windowSize);
}
}
naiveSample() {
const { sample: { values, n }, windowSize,result } = this
for (let start = 0; start + windowSize <= n; start++) {
let sumWindow = 0;
for (let i = start; i < start + windowSize; i++) { sumWindow += values[i] }
result.push(sumWindow / windowSize);
}
}
}
class Noice {
constructor(sample) {
this.sample = sample
this.zThreshold = 2.5 + (0.5 * Math.abs(sample.skewness)); // Dynamic z threshold depends on skewness
}
get noiseByRelativeDispersion() { return this.sample.relativeDispersion < 1 }
get noiseByCV() { return this.sample.cv < 1 }
get noiseByIQR() { return this.sample.iqr < this.sample.stdDev }
get noiseBySkewness() { return Math.abs(this.sample.skewness) < 1 }
noiseByZ(min = 0.01) {
const { zScores, zThreshold, n, relativeDispersion } = this.sample
const outliersAbove = zScores.filter(z => z > zThreshold).length / n;
const outliersBelow = zScores.filter(z => z < -zThreshold).length / n;
return (outliersAbove + outliersBelow) < Math.min(min, min * relativeDispersion);
}
}
function confidenceInterval(sample) {
const tTable95 = {
1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571,
6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228,
11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145, 15: 2.131,
16: 2.120, 17: 2.110, 18: 2.101, 19: 2.093, 20: 2.086,
21: 2.080, 22: 2.074, 23: 2.069, 24: 2.064, 25: 2.060,
26: 2.056, 27: 2.052, 28: 2.048, 29: 2.045, 30: 2.042
};
const { n, mean, stdDevSample } = sample;
if (n < 2) return { low: mean, high: mean, width: 0 };
const df = n - 1;
let criticalValue = df <= 30 ? tTable95[df] : 1.96;
const margin = criticalValue * (stdDevSample / Math.sqrt(n)); // Если стандартное отклонение равно 0, margin будет равен 0
const low = mean - margin;
const high = mean + margin;
const width = mean !== 0 ? ((high - low) / Math.abs(mean)) * 100 : (high - low); // Если mean равен 0, нельзя вычислять относительную ширину делением на mean.
return { low, high, width };
}
function mode(values) {
const freq = new Map();
let maxFreq = 0;
for (let num of values) {
const count = (freq.get(num) || 0) + 1;
freq.set(num, count);
if (count > maxFreq) maxFreq = count;
}
const modes = [];
for (const [val, count] of freq.entries()) {
if (count === maxFreq) modes.push(val);
}
return modes.length === values.length ? [] : modes;
}
function outliersZScore({ zScores, values },threshold = 3, twoFactors = true) {
const outliers = [], indexes = [], zs = [];
for (let i = 0; i < zScores.length; i++) {
const z = twoFactors ? Math.abs(zScores[i]) : zScores[i]
if (z <= threshold) continue
outliers.push(values[i]);
indexes.push(i)
zs.push(zScores[i])
}
return { values: outliers, indexes, zScores: zs };
}
function weightedMean(weights,values) {
if (!Array.isArray(weights) || weights.length !== values.length) throw new Error("Weights must be an array of the same length as values");
let wSum = 0, valSum = 0;
for (let i = 0; i < values.length; i++) {
wSum += weights[i];
valSum += values[i] * weights[i];
}
return wSum === 0 ? 0 : valSum / wSum;
}
const EPS = 3e-14;
const FPMIN = 1e-30;
const P = [676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
const G = 7;
function betacfNR(x, a, b) {
const MAXIT = 200;
let qab = a + b;
let qap = a + 1;
let qam = a - 1; // используется только для инициализации, но оставим для ясности
let c = 1.0;
let d = 1.0 - (qab * x) / qap;
if (Math.abs(d) < FPMIN) d = FPMIN;
d = 1.0 / d;
let h = d;
for (let m = 1; m <= MAXIT; m++) {
const m2 = 2 * m;
// Even step
let aa = m * (b - m) * x / ((a + m2 - 1) * (a + m2));
d = 1.0 + aa * d;
if (Math.abs(d) < FPMIN) d = FPMIN;
c = 1.0 + aa / c;
if (Math.abs(c) < FPMIN) c = FPMIN;
d = 1.0 / d;
h *= (d * c);
// Odd step
aa = - (a + m) * (qab + m) * x / ((a + m2) * (a + m2 + 1));
d = 1.0 + aa * d;
if (Math.abs(d) < FPMIN) d = FPMIN;
c = 1.0 + aa / c;
if (Math.abs(c) < FPMIN) c = FPMIN;
d = 1.0 / d;
let delta = d * c;
h *= delta;
if (Math.abs(delta - 1.0) < EPS) {
break;
}
}
return h;
}
function gammaLn(z) {
if (z < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * z)) - gammaLn(1 - z);
else {
z -= 1;
let x = 0.99999999999980993;
for (let i = 0; i < P.length; i++) {
x += P[i] / (z + i + 1);
}
const t = z + G + 0.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
}
function betaLn(a, b) { return gammaLn(a) + gammaLn(b) - gammaLn(a + b) }
function regularizedIncompleteBeta(x, a, b) {
if (x <= 0) return 0;
if (x >= 1) return 1;
const bt = Math.exp(a * Math.log(x) + b * Math.log(1 - x) - betaLn(a, b));
let sym = false;
let xx = x, aa = a, bb = b;
if (x > (a + 1) / (a + b + 2)) {
sym = true;
xx = 1 - x;
aa = b;
bb = a;
}
const cf = betacfNR(xx, aa, bb);
let val = bt / aa * cf;
if (sym) val = 1 - val;
return val;
}
function tCDF(t, df) {
if (df <= 0) throw new Error("Degrees of freedom must be positive");
if (t === 0) return 0.5;
const x = df / (df + t * t);
const a = df / 2;
const b = 0.5;
const ibeta = regularizedIncompleteBeta(x, a, b);
return (t >= 0) ? 1 - 0.5 * ibeta : 0.5 * ibeta;
}
class PearsonP {
constructor(n, cov,sigX, sigY) {
this.r = (sigX === 0 || sigY === 0) ? 0 : cov / (sigX * sigY)
this.df = n - 2;
this.t = 0;
this.p = 1;
if (this.df > 1) this.calculateP(this)
}
get significant() { return this.p < 0.0001 }
get sig() { return (this.p < 0.0001) ? "<0.0001" : this.p.toFixed(4) }
calculateP({ r, df }) {
this.t = r * Math.sqrt(df / Math.max(1 - r * r, 1e-16));
this.p = 2 * (1 - tCDF(Math.abs(this.t), df));
if (this.p < 0) this.p = 0;
if (this.p > 1) this.p = 1;
}
}
class Comparative {
constructor(sample1, sample2) {
if (sample1.n !== sample2.n) throw new Error("Length of samples must match");
this.sample1 = sample1; this.sample2 = sample2;
this.n = this.sample1.n
}
get sumCov() {
const { sample1: { mean: m1, values: values1 }, sample2: { mean: m2, values: values2 }, n } = this
let sumCov = 0;
for (let i = 0; i < n; i++) { sumCov += (values1[i] - m1) * (values2[i] - m2) }
return sumCov
}
get covariancePopulation() { return this.sumCov / this.n }
get covarianceSample() { return this.n < 2 ? 0 : this.sumCov / (this.n - 1) }
get correlationPopulation() { return this.pearsonPopulation.r }
get correlationSample() { return this.pearsonSample.r }
get pearsonPopulation() {
return new PearsonP(this.n, this.covariancePopulation, this.sample1.stdDevPopulation, this.sample2.stdDevPopulation)
}
get pearsonSample() {
return new PearsonP(this.n, this.covarianceSample, this.sample1.stdDevSample, this.sample2.stdDevSample)
}
twoSampleTTest() {
const { n, sample1: { mean: m1, varianceSample: var1 }, sample2: { mean: m2, varianceSample: var2 } } = this
const pooledVariance = ((n - 1) * var1 + (n - 1) * var2) / (2 * n - 2);
const sp = Math.sqrt(pooledVariance);
const t = (m1 - m2) / (sp * Math.sqrt(2 / n));
const df = 2 * n - 2;
const F = var1 / var2;
return { t, df, F };
}
}
class RatioColumn extends Column {
constructor(values, columnFilter) {
super(values, columnFilter, Number)
}
get sum() { return this.cached('sum', () => this.values.reduce((acc, val) => acc + val, 0)) }
get mean() { return this.cached('mean', () => this.sum / this.n) }
get sorted() { return this.cached('sorted', () => this.values.slice().sort((a, b) => a - b)) }
get min() { return this.cached('min', () => this.sorted[0]) }
get max() { return this.cached('max', () => this.sorted[this.n - 1]) }
get range() { return this.cached('range', () => this.max - this.min) }
get variance() { return this.variancePopulation }
get stdDev() { return this.stdDevPopulation }
get skewness() { return this.skewnessPopulation }
get kurtosis() { return this.kurtosisPopulation }
get cv() { return this.cached('cv', () => this.mean === 0 ? 0 : this.stdDevPopulation / this.mean) } // Coefficient of Variation - stdDevPopulation / mean
get iqr() { return this.cached('iqr', () => this.q3 - this.q1) }
outliersZScore(threshold = 3, twoFactors = true) { return outliersZScore(this, threshold, twoFactors) }
weightedMean(weights) { return weightedMean(weights, this.values) }
noice() { return new Noice(this) }
ma(windowSize) { return new MovingAverage(this, windowSize).result }
get variancePopulation() {
return this.cached('variancePopulation', () => {
return this.values.reduce((acc, val) => acc + Math.pow(val - this.mean, 2), 0) / this.n;
});
}
get stdDevPopulation() { return this.cached('stdDevPopulation', () => Math.sqrt(this.variancePopulation)) }
get zScores() {
return this.cached('zScores', () => {
const { mean, stdDevPopulation, values } = this;
if (stdDevPopulation === 0) return values.map(v => 0);
return values.map(v => (v - mean) / stdDevPopulation);
});
}
get skewnessPopulation() {
return this.cached('skewnessPopulation', () => {
const { values, mean, stdDevPopulation, n } = this;
if (stdDevPopulation === 0) return 0; // If stdDev = 0
return values.reduce((acc, v) => acc + ((v - mean) / stdDevPopulation) ** 3, 0) / n;
});
}
get kurtosisPopulation() {
return this.cached('kurtosisPopulation', () => {
const sumZ4 = this.zScores.reduce((acc, z) => acc + z ** 4, 0);
return (sumZ4 / this.n) - 3;
});
}
get varianceSample() { // Sample (selected): Bessel's correction. s^2 = (1/(n-1)) * sum( (x - mean)^2 )
return this.cached('varianceSample', () => {
if (this.n < 2) return 0;
return this.values.reduce((acc, val) => acc + (val - this.mean) ** 2, 0) / (this.n - 1);
});
}
get stdDevSample() { return this.cached('stdDevSample', () => Math.sqrt(this.varianceSample)) }
get relativeDispersion() { return this.median === 0 ? 0 : this.stdDev / this.median }
get skewnessSample() {
return this.cached('skewnessSample', () => {
if (this.n < 3) return 0;
const { mean, values, n, stdDevSample } = this;
const numerator = values.reduce((acc, val) => acc + ((val - mean) / stdDevSample) ** 3, 0);
const factor = n / ((n - 1) * (n - 2));
return factor * numerator;
});
}
get kurtosisSample() {
return this.cached('kurtosisSample', () => {
if (this.n < 4) return 0;
const { mean, values, n, stdDevSample } = this;
const z4sum = values.reduce((acc, val) => acc + Math.pow((val - mean) / stdDevSample, 4), 0);
const a = (n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3));
const b = 3 * Math.pow(n - 1, 2) / ((n - 2) * (n - 3));
return a * z4sum - b;
});
}
get mode() { return this.cached('mode', () => mode(this.values)) }
get normalizedValues() { // Min-Max scaling
return this.cached('normalizedValues', () => {
const { min, range, values } = this;
if (range === 0) return values.map(() => 0);
return values.map(v => (v - min) / range);
});
}
get geometricMean() { // GM = (П(i=1..n) x_i)^(1/n). Only for x_i > 0.
return this.cached('geometricMean', () => {
if (!this.values.every(v => v > 0)) return NaN;
const logSum = this.values.reduce((acc, v) => acc + Math.log(v), 0);
return Math.exp(logSum / this.n);
});
}
get harmonicMean() { // HM = n / Σ(1/x_i) . Only for x_i > 0
return this.cached('harmonicMean', () => {
if (!this.values.every(v => v > 0)) return NaN;
const denom = this.values.reduce((acc, v) => acc + 1 / v, 0);
return this.n / denom;
});
}
get flatness() { return this.cached('flatness', () => this.geometricMean / this.mean) }
get sumOfSquares() { return this.cached('sumOfSquares', () => this.values.reduce((acc, v) => acc + v ** 2, 0)) } // Сумма квадратов (энергетическая норма). Σ(x_i^2)
get confidenceInterval95() { return this.cached('confidenceInterval95', () => confidenceInterval(this)) }
get outliersIQR() {
const { q1, q3, iqr, values } = this;
const lowerBound = q1 - 1.5 * iqr, upperBound = q3 + 1.5 * iqr;
return values.filter(v => v < lowerBound || v > upperBound);
}
get noiseStability() {
return this.cached('noiseStability', () => {
const { values, mean, n } = this;
if (n < 2) return Array(n).fill(0);
const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / n;
const stdDev = Math.sqrt(variance);
return stdDev;
});
}
get spectralPowerDensityArray() {
return this.cached('spectralPowerDensityArray', () => {
const { values, sumOfSquares } = this;
if (sumOfSquares === 0) return values.map(() => 0);
return values.map(v => (v ** 2) / sumOfSquares);
});
}
get spectralPowerDensityMetric() {
return this.cached('spectralPowerDensityMetric', () => {
const spdArray = this.spectralPowerDensityArray;
const geometricMean = Math.exp(spdArray.reduce((sum, x) => sum + Math.log(x + Number.EPSILON), 0) / spdArray.length);
const arithmeticMean = spdArray.reduce((sum, x) => sum + x, 0) / spdArray.length;
return geometricMean / arithmeticMean; // Близкое к 1 → равномерный шум, ближе к 0 → голос
});
}
get xValues() { return this.cached('xValues', () => Array.from({ length: this.n }, (_, i) => i + 1)) }
regressionSlope(customX = null) {
const x = customX || this.xValues;
if (!Array.isArray(x) || x.length !== this.n) throw new Error("x must be an array of same length as values");
const xCol = new RatioColumn(x);
const cov = new Comparative(xCol, this).covarianceSample;
const varX = xCol.varianceSample;
return varX === 0 ? 0 : cov / varX;
}
}
function newColumn(values,table) {
return getType(values) === Number
? new RatioColumn(values, table, Number)
: new Column(values, table, String)
}
function extractMetric(col, metricName) {
const keys = metricName.split('.')
let value = col;
for (let i = 0; i < keys.length; i++) {
if (value === undefined) break;
value = value[keys[i]];
if (Array.isArray(value) && value.length) value = newColumn(value)
}
return value;
}
function extractMetrics(columns,metricName) {
return Object.values(columns)
.map(col => extractMetric(col, metricName))
.filter(v => v !== undefined)
}
function transpose(table, { n, columns, filtered, isFiltered }) {
let $columns = Object.values(columns).map(col => col.type === Number ? col.values : null)
let j = 0;
for (let i = 0; i < n; i++) {
if (isFiltered && filtered[i]) continue
table.addColumn(j, $columns.map(values => values[j]))
j++
}
return table
}
function filterColumns(columns, columnFilter) {
const includeNames = [], excludeNames = [], regexPatterns = [];
if (Array.isArray(columnFilter)) { // Разделяем фильтры
columnFilter.forEach(filter => {
if (typeof filter === 'number') includeNames.push(filter)
else if (typeof filter === "string") {
if (filter.startsWith("-")) excludeNames.push(filter.slice(1));
else includeNames.push(filter);
} else if (filter instanceof RegExp) {
regexPatterns.push(filter);
}
});
}
const columnNames = Object.keys(columns);
let filteredNames = columnNames;
if (includeNames.length > 0) filteredNames = includeNames; // Если есть явные имена, берём только их
else if (regexPatterns.length > 0) { // Если есть regex, фильтруем по ним
filteredNames = columnNames.filter(name =>
regexPatterns.some(pattern => pattern.test(name))
);
}
if (excludeNames.length > 0) filteredNames = filteredNames.filter(name => !excludeNames.includes(name)); // Применяем исключения
return filteredNames;
}
function addRow(obj, index = null, table) {
// Проверка соответствия ключей и типов
const colNames = Object.keys(table.columns);
for (let key of Object.keys(obj)) {
if (!colNames.includes(key)) return false;
const colType = table.columns[key].type;
if (colType === Number && typeof obj[key] !== 'number') return false;
if (colType === String && typeof obj[key] !== 'string') return false;
}
for (let colName of colNames) {
if (!(colName in obj)) return false;
}
if (index === null || index >= table.n) { // Добавление строки
for (let [colName, value] of Object.entries(obj)) { // Добавление в конец
table.columns[colName]._values.push(value);
}
table.n++;
} else { // Вставка по индексу
for (let [colName, value] of Object.entries(obj)) {
table.columns[colName]._values.splice(index, 0, value);
}
table.n++;
// Обновление фильтра
const updatedFiltered = {};
for (let i in table.filtered) {
const numIndex = Number(i);
if (numIndex >= index) updatedFiltered[numIndex + 1] = true;
else updatedFiltered[numIndex] = true;
}
table.filtered = updatedFiltered;
if (table.maxFilter >= index) table.maxFilter++;
}
table.filterState++;
return true;
}
class CronbachAlpha {
static cronbachAlpha(table, obj = {}) {
const { columnsN } = table;
if (columnsN < 2) throw new Error("For Alpha Cronbach needs >=2 ratio columns.");
obj.sumOfVariances = table.descriptive('varianceSample').sum;
obj.sumColumnVariance = table.compute(row => Object.values(row).reduce((s, v) => s + v, 0)).varianceSample;
obj.bessel = columnsN / (columnsN - 1);
obj.alpha = obj.sumColumnVariance === 0 ? 0 : obj.bessel * (1 - obj.sumOfVariances / obj.sumColumnVariance);
return obj;
}
constructor(table) {
this.table = table;
CronbachAlpha.cronbachAlpha(table, this);
}
get perColumn() { return this.ifItemsDeleted(); }
ifItemsDeleted(columnFilter) {
const result = {};
for (let colName in this.table.columns) {
const filter = columnFilter || ['-' + colName];
result[colName] = CronbachAlpha.cronbachAlpha(this.table.clone(true, filter)).alpha;
}
return result;
}
}
class Dbscan {
constructor(table, eps = 0.4, minPts = 3) {
if (table.columnsN < 2) throw new Error('2 or more columns required')
this.table = table;
this.eps = eps;
this.minPts = minPts;
this.columnsArray = Object.entries(table.columns);
this.n = this.columnsArray.length;
this.labels = new Array(this.n).fill(0); // 0: не обработан, -1: шум, 1+: кластер
this.clusters = [];
this.distances = null;
this.computeDistances();
this.run();
this.buildClusters();
}
computeDistances() { // Вычисление матрицы расстояний
this.distances = new Array(this.n).fill(null).map(() => new Array(this.n).fill(null));
for (let i = 0; i < this.n; i++) {
for (let j = i + 1; j < this.n; j++) {
const { correlationSample } = this.table.compare(this.columnsArray[i][0], this.columnsArray[j][0]);
const dist = 1 - correlationSample;
this.distances[i][j] = dist;
this.distances[j][i] = dist;
}
this.distances[i][i] = 0; // расстояние до себя = 0
}
}
findNeighbors(pointIdx) { // Поиск соседей для точки
const neighbors = [];
for (let i = 0; i < this.n; i++) {
if (this.distances[pointIdx][i] <= this.eps) {
neighbors.push(i);
}
}
return neighbors;
}
expandCluster(pointIdx, clusterId) { // Расширение кластера
this.labels[pointIdx] = clusterId;
const seeds = [...this.findNeighbors(pointIdx).filter(idx => idx !== pointIdx)];
while (seeds.length > 0) {
const current = seeds.pop();
if (this.labels[current] === -1) this.labels[current] = clusterId; // шум в кластер
if (this.labels[current] !== 0) continue; // уже обработан
this.labels[current] = clusterId;
const currentNeighbors = this.findNeighbors(current);
if (currentNeighbors.length >= this.minPts) {
seeds.push(...currentNeighbors.filter(idx => this.labels[idx] === 0));
}
}
}
run() { // Основной алгоритм DBSCAN
let clusterId = 0;
for (let i = 0; i < this.n; i++) {
if (this.labels[i] !== 0) continue; // уже обработан
const neighbors = this.findNeighbors(i);
if (neighbors.length < this.minPts) {
this.labels[i] = -1; // шум
continue;
}
clusterId++;
this.expandCluster(i, clusterId);
}
}
buildClusters() { // Формирование кластеров как таблиц
for (let id = 1; id <= Math.max(...this.labels); id++) {
const clusterTable = new this.table.Table();
this.columnsArray.forEach(([name, column], idx) => {
if (this.labels[idx] === id) {
clusterTable.addColumn(name, column.clone(true));
}
});
if (clusterTable.columnsN > 0) this.clusters.push(clusterTable);
}
}
}
class Hdbscan {
constructor(table, minClusterSize = 2) {
if (table.columnsN < 2) throw new Error('2 or more columns required');
this.table = table;
this.minClusterSize = minClusterSize;
this.columnsArray = Object.entries(table.columns);
this.n = this.columnsArray.length;
this.labels = new Array(this.n).fill(-1);
this.clusters = [];
this.distances = null;
this.mreachDistances = null;
this.mst = [];
this.hierarchy = [];
this.computeDistances();
this.computeMutualReachability();
this.buildMST();
this.buildHierarchy();
this.extractClusters();
this.buildClusters();
}
computeDistances() {
this.distances = new Array(this.n).fill(null).map(() => new Array(this.n).fill(null));
for (let i = 0; i < this.n; i++) {
for (let j = i + 1; j < this.n; j++) {
const { correlationSample } = this.table.compare(this.columnsArray[i][0], this.columnsArray[j][0]);
const dist = isNaN(correlationSample) ? 1 : 1 - correlationSample;
this.distances[i][j] = dist;
this.distances[j][i] = dist;
}
this.distances[i][i] = 0;
}
}
computeMutualReachability() {
this.mreachDistances = new Array(this.n).fill(null).map(() => new Array(this.n).fill(null));
const coreDistances = new Array(this.n);
for (let i = 0; i < this.n; i++) {
const distances = this.distances[i].slice().sort((a, b) => a - b);
coreDistances[i] = distances[Math.min(this.minClusterSize - 1, this.n - 1)];
}
for (let i = 0; i < this.n; i++) {
for (let j = i; j < this.n; j++) {
const mreach = Math.max(coreDistances[i], coreDistances[j], this.distances[i][j]);
this.mreachDistances[i][j] = mreach;
this.mreachDistances[j][i] = mreach;
}
}
}
buildMST() {
const visited = new Array(this.n).fill(false);
const key = new Array(this.n).fill(Infinity);
const parent = new Array(this.n).fill(-1);
key[0] = 0;
for (let count = 0; count < this.n - 1; count++) {
let minKey = Infinity;
let u = -1;
for (let v = 0; v < this.n; v++) {
if (!visited[v] && key[v] < minKey) {
minKey = key[v];
u = v;
}
}
visited[u] = true;
if (parent[u] !== -1) this.mst.push([parent[u], u, this.mreachDistances[parent[u]][u]]);
for (let v = 0; v < this.n; v++) {
if (!visited[v] && this.mreachDistances[u][v] < key[v]) {
key[v] = this.mreachDistances[u][v];
parent[v] = u;
}
}
}
this.mst.sort((a, b) => a[2] - b[2]);
}
buildHierarchy() {
const uf = new UnionFind(this.n);
const clusterSizes = new Array(this.n).fill(1);
let clusterId = 0; // Начинаем с 0 для уникальных кластеров
this.hierarchy = [];
for (let i = 0; i < this.n; i++) {
this.hierarchy.push({ clusterId: i, lambdaBirth: 0, lambdaDeath: Infinity, points: [i], size: 1 });
}
for (const [p, q, weight] of this.mst) {
const lambda = 1 / weight;
const cp = uf.find(p);
const cq = uf.find(q);
if (cp !== cq) {
const sizeP = clusterSizes[cp];
const sizeQ = clusterSizes[cq];
uf.union(cp, cq);
const newRoot = uf.find(cp);
clusterSizes[newRoot] = sizeP + sizeQ;
// Обновляем lambdaDeath для старых кластеров
this.hierarchy[cp].lambdaDeath = lambda;
this.hierarchy[cq].lambdaDeath = lambda;
// Добавляем новый кластер
this.hierarchy.push({
clusterId: clusterId + this.n,
lambdaBirth: lambda,
lambdaDeath: Infinity,
points: this.hierarchy[cp].points.concat(this.hierarchy[cq].points),
size: sizeP + sizeQ
});
clusterId++;
}
}
}
extractClusters() {
// Вычисляем стабильность каждого кластера
const stability = new Map();
this.hierarchy.forEach(cluster => {
if (cluster.size >= this.minClusterSize) {
const stabilityValue = cluster.size * (cluster.lambdaDeath - cluster.lambdaBirth);
stability.set(cluster.clusterId, stabilityValue);
}
});
// Извлекаем кластеры, сравнивая стабильность
const activeClusters = new Map();
this.hierarchy.forEach(cluster => {
if (cluster.size < this.minClusterSize) return;
let isStable = true;
const childrenStability = cluster.points.reduce((sum, p) => {
const child = this.hierarchy.find(c => c.clusterId === p);
return sum + (stability.get(child.clusterId) || 0);
}, 0);
if (childrenStability > stability.get(cluster.clusterId)) {
isStable = false;
}
if (isStable) {
activeClusters.set(cluster.clusterId, cluster.points);
}
});
// Присваиваем метки точкам
let clusterId = 1;
const assigned = new Set();
activeClusters.forEach((points, id) => {
if (points.length >= this.minClusterSize) {
points.forEach(p => {
if (!assigned.has(p)) {
this.labels[p] = clusterId;
assigned.add(p);
}
});
clusterId++;
}
});
// Отладка
console.log('Points:', this.n);
console.log('Labels:', this.labels);
console.log('Clusters found:', new Set(this.labels.filter(l => l !== -1)).size);
}
buildClusters() {
const maxLabel = Math.max(...this.labels);
if (maxLabel < 1) {
console.error('No clusters found');
return;
}
for (let id = 1; id <= maxLabel; id++) {
const clusterTable = new this.table.Table();
this.columnsArray.forEach(([name, column], idx) => {
if (this.labels[idx] === id) {
clusterTable.addColumn(name, column.clone(true));
}
});
if (clusterTable.columnsN > 0) this.clusters.push(clusterTable);
}
}
}
class UnionFind {
constructor(n) {
this.parent = new Array(n).fill(null).map((_, i) => i);
this.rank = new Array(n).fill(0);
}
find(x) {
if (this.parent[x] !== x) this.parent[x] = this.find(this.parent[x]);
return this.parent[x];
}
union(x, y) {
const px = this.find(x);
const py = this.find(y);
if (px === py) return;
if (this.rank[px] < this.rank[py]) {
this.parent[px] = py;
} else if (this.rank[px] > this.rank[py]) {
this.parent[py] = px;
} else {
this.parent[py] = px;
this.rank[px]++;
}
}
}
const MatrixUtils = {
transpose(A) {
return A[0].map((_, i) => A.map(row => row[i]));
},
multiply(A, B) {
const result = Array.from({ length: A.length }, () => Array(B[0].length).fill(0));
for (let i = 0; i < A.length; i++) {
for (let j = 0; j < B[0].length; j++) {
for (let k = 0; k < B.length; k++) {
result[i][j] += A[i][k] * B[k][j];
}
}
}
return result;
},
multiplyVec(A, b) {
return A.map(row => row.reduce((acc, aij, j) => acc + aij * b[j], 0));
},
inverse(A) {
const n = A.length;
const I = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
const AI = A.map((row, i) => [...row, ...I[i]]);
for (let i = 0; i < n; i++) {
let maxRow = i;
for (let k = i + 1; k < n; k++) {
if (Math.abs(AI[k][i]) > Math.abs(AI[maxRow][i])) maxRow = k;
}
[AI[i], AI[maxRow]] = [AI[maxRow], AI[i]];
const diag = AI[i][i];
for (let j = 0; j < 2 * n; j++) AI[i][j] /= diag;
for (let k = 0; k < n; k++) {
if (k !== i) {
const factor = AI[k][i];
for (let j = 0; j < 2 * n; j++) AI[k][j] -= factor * AI[i][j];
}
}
}
return AI.map(row => row.slice(n));
}
};
class LinearRegression {
constructor(table, yName, xNames = []) {
this.table = table;
this.yName = yName;
this.xNames = xNames
this._originalX = xNames.length > 0 ? xNames : Object.keys(table.columns).filter(k => k !== yName);
this._mediator = null;
this._moderator = null;
this._interactionName = null;
}
mediator(name) { this._mediator = name; return this }
moderator(name) { this._moderator = name; return this }
calculate() {
const fullCols = { ...this.table.columns };
this.xNames = [...this._originalX];
if (this._mediator && !this.xNames.includes(this._mediator)) this.xNames.push(this._mediator); // Медиатор
if (this._moderator) {
const x = this.table.columns[this._originalX[0]].values;
const z = this.table.columns[this._moderator].values;
this._interactionName = `${this._originalX[0]}*${this._moderator}`;
fullCols[this._interactionName] = { values: x.map((xi, i) => xi * z[i]) };
if (!this.xNames.includes(this._moderator)) this.xNames.push(this._moderator);
this.xNames.push(this._interactionName);
}
this.y = this.table.columns[this.yName].values;
this.X = fullCols[this.xNames[0]].values.map((_, i) => [1, ...this.xNames.map(name => fullCols[name].values[i])]);
this.n = this.X.length;
this.k = this.X[0].length;
this.coefficients = this.computeCoefficients();
if (this.coefficients.some(c => !isFinite(c))) throw new Error("Regression failed: singular matrix or constant predictors");
this.yHat = this.predict(this.X);
this.residuals = this.y.map((yi, i) => yi - this.yHat[i]);
this.r2 = this.computeR2();
this.standardErrors = this.computeStandardErrors();
this.pValues = this.computePValues();
return this;
}
computeCoefficients() {
const XT = MatrixUtils.transpose(this.X);
const XTX = MatrixUtils.multiply(XT, this.X);
const XTy = MatrixUtils.multiplyVec(XT, this.y);
return MatrixUtils.solve ? MatrixUtils.solve(XTX, XTy) : MatrixUtils.multiplyVec(MatrixUtils.inverse(XTX), XTy);
}
predict(X) { return X.map(row => row.reduce((acc, val, j) => acc + val * this.coefficients[j], 0)) }
computeR2() {
const yMean = this.y.reduce((a, b) => a + b, 0) / this.n;
const ssTot = this.y.reduce((acc, yi) => acc + (yi - yMean) ** 2, 0);
const ssRes = this.residuals.reduce((acc, e) => acc + e ** 2, 0);
return 1 - ssRes / ssTot;
}
computeStandardErrors() {
const XT = MatrixUtils.transpose(this.X);
const XTX = MatrixUtils.multiply(XT, this.X);
const invXTX = MatrixUtils.inverse(XTX);
const mse = this.residuals.reduce((acc, e) => acc + e ** 2, 0) / (this.n - this.k);
return invXTX.map((row, i) => Math.sqrt(row[i] * mse));
}
computePValues() {
return this.standardErrors.map((se, i) => {
const t = this.coefficients[i] / se;
return 2 * (1 - tCDF(Math.abs(t), this.n - this.k));
});
}
get result() {
if(!this.xNames) this.calculate()
return {
Variable: ['Intercept', ...this.xNames],
Coefficient: this.coefficients,
StdError: this.standardErrors,
pValue: this.pValues
};
}
get htmlTable() {
const { Variable, Coefficient, StdError, pValue } = this.result
return /*html*/`<table>
<thead><tr style="text-align:left;">${['Variable', 'Coefficient', 'StdError', 'pValue'].map(v => /*html*/`<th>${v}</th>`).join('')}</tr></thead>
<tbody>
${Variable.map((vName, i) => /*html*/`<tr>
<th style="text-align:left;">${vName}</th>
<td>${Coefficient[i].toFixed(3)}</td>
<td>${StdError[i].toFixed(3)}</td>
<td>${pValue[i].toFixed(5)}</td>
</tr>`).join('')}
</tbody>
</table>`
}
}
class Table extends ColumnFilter {
constructor(data) {
super()
this.n = 0;
this.columns = {}
this.columnsN = 0
if(data && typeof data === 'object' && !Array.isArray(data) && data !== null) {
for (let colName in data) this.addColumn(colName, data[colName])
}
}
get Table() { return Table }
get cronbachAlpha() { return new CronbachAlpha(this) }
addRow(obj, index) { return addRow(obj, index, this) }
deleteColumn(name) {
if (!this.columns[name]) return;
this.columns[name].columnFilter = new ColumnFilter();
delete this.columns[name];
const colValues = Object.values(this.columns)
this.columnsN = colValues.length
this.n = Math.max(...colValues.map(col => col.n), 0);
return this
}
addColumn(name, values = []) {
this.columns[name] = values.constructor.name.includes('Column') ? values : newColumn(values, this)
if (this.columns[name].n > this.n) this.n = this.columns[name].n
this.columnsN++
return this.columns[name]
}
filterRowsBy(colName, fn) {
if (this.columns[colName]) this.columns[colName].filterRowsBy(fn);
return this
}
clone(filtered = true, columnFilter = null) {
const table = new Table();
const filteredNames = filterColumns(this.columns, columnFilter);
for (let columnName of filteredNames) {
if (!this.columns[columnName]) continue
table.addColumn(columnName, this.columns[columnName].clone(filtered, table));
}
return table;
}
compare(colName1, colName2) { return new Comparative(this.columns[colName1], this.columns[colName2]) }
transpose() { return transpose(new Table(), this) }
descriptive(metricName, table) { return newColumn(extractMetrics(this.columns, metricName), table) }
compute(fn, targetName) {
return targetName ? this.addColumn(targetName, compute(fn, this)) : newColumn(compute(fn, this))
}
where(fn) { return compute((obj, i) => {if(!this.filtered[i] && fn(obj, i)) return i }, this).filter((v) => v !== undefined) }
dbscan(eps = 0.4, minPts = 3) { return new Dbscan(this, eps, minPts) }
hdbscan(minPts = 3) { return new Hdbscan(this, minPts) }
linearRegression(yName, xNames) { return new LinearRegression(this,yName,xNames) }
get json() {
const obj = {}
for(const colName in this.columns) {
obj[colName] = this.columns[colName].values
}
return obj
}
}
const range = (start, end, step = 1) => Array.from({ length: Math.floor((end - start) / step) }, (v, i) => start + i * step);
class Statistics {
static range = range
static statistics = new Statistics('default-statistics')
static newTable(data) { return new Table(data) }
static newColumn(values) { return newColumn(values) }
constructor() {
this.tables = {}
}
addTable(name,obj) { this.tables[name] = new Table(obj); return this.tables[name] }
filterRows(indexes) { for (let n in this.tables) { this.tables[n].filterRows(indexes) }; return this }
clearRowsFilters(indexes) { for (let n in this.tables) { this.tables[n].clearRowsFilters(indexes) }; return this }
clearAllRowsFilters() { for (let n in this.tables) { this.tables[n].clearAllRowsFilters() }; return this }
descriptive(metricName) {
const newTable = new Table()
Object.entries(this.tables).forEach(([name,table]) => {
newTable.addColumn(name,table.descriptive(metricName,newTable))
})
return newTable
}
transpose() {
const newStatistics = new Statistics()
for (let n in this.tables) {
newStatistics.tables[n+'_transposed'] = this.tables[n].transpose(n)
}
return newStatistics
}
}
return Statistics
})()