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

1,270 lines (1,224 loc) 74.9 kB
const Statistics = (function(){ function betacfNR(x, a, b, {EPS,FPMIN}) { 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; 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); 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, { P, G }) { 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); } } const constants = { EPS: 3e-14, FPMIN: 1e-30, P: [676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7], G: 7, } class CDF { static constants = constants static regularizedIncompleteBeta(x, a, b) { const { constants } = CDF if (x <= 0) return 0; if (x >= 1) return 1; const betaLn = gammaLn(a, constants) + gammaLn(b, constants) - gammaLn(a + b, constants) const bt = Math.exp(a * Math.log(x) + b * Math.log(1 - x) - betaLn); let sym = false, xx = x, aa = a, bb = b; if (x > (a + 1) / (a + b + 2)) { sym = true; xx = 1 - x; aa = b; bb = a; } let val = bt / aa * betacfNR(xx, aa, bb, constants); if (sym) val = 1 - val; return val; } static t(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), a = df / 2, b = 0.5; const ibeta = CDF.regularizedIncompleteBeta(x, a, b); return (t >= 0) ? 1 - 0.5 * ibeta : 0.5 * ibeta; } static f(F, df1, df2) { if (F < 0 || df1 <= 0 || df2 <= 0) throw new Error("Invalid input for F-distribution"); const x = (df1 * F) / (df1 * F + df2), a = df1 / 2, b = df2 / 2; return CDF.regularizedIncompleteBeta(x, a, b); } static phi(x) { if (!Number.isFinite(x)) return x === Infinity ? 1 : x === -Infinity ? 0 : NaN; if (x > 8) return 1; if (x < -8) return 0; const y = Math.abs(x) / Math.SQRT2; const t = 1 / (1 + 0.3275911 * y); const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741, a4 = -1.453152027, a5 = 1.061405429; const poly = (((((a5 * t) + a4) * t) + a3) * t + a2) * t + a1; const erf_abs = 1 - poly * t * Math.exp(-y * y); const erf = x < 0 ? -erf_abs : erf_abs; return 0.5 * (1 + erf); } } const n = (v) => v === undefined class BaseStats { #sum; #mean; #v; #vS; constructor(values, name) { if (!Array.isArray(values)) throw new Error(`${name} must be array or Column instance`); this.values = values.filter(Number.isFinite); this.n = this.values.length; this.name = name; } get sum() { if (n(this.#sum)) this.#sum = this.values.reduce((t, v) => t + v, 0); return this.#sum; } get mean() { if (n(this.#mean)) this.#mean = this.n ? this.sum / this.n : NaN; return this.#mean; } get variance() { if (n(this.#v)) this.#v = this.n ? this.values.reduce((t, v) => t + (v - this.mean) ** 2, 0) / this.n : NaN; return this.#v; } get stdDev() { return Math.sqrt(this.variance); } get varianceSample() { if (n(this.#vS)) this.#vS = (this.n < 2) ? NaN : this.values.reduce((t, v) => t + (v - this.mean) ** 2, 0) / (this.n - 1); return this.#vS; } get stdDevSample() { return Math.sqrt(this.varianceSample); } get weight() { return this.varianceSample > 0 ? this.n / this.varianceSample : 0; } } function stats(vs, name, options = {}) { const { min } = options; const s = (vs && vs.constructor && vs.constructor.name === 'Column' || vs instanceof BaseStats) ? vs : new BaseStats(vs, name); if (min && s.n < min) throw new Error(`${name} length should be minimum ${min}, got ${s.n}`); return s; } function round(d, max = 8) { if (d === 0) return 0; if (d == null) return 0; const dd = Number(d); if (!Number.isFinite(dd)) return 0; const rounded = Math.trunc(dd); if (dd === rounded) return dd; let frac = Math.abs(dd - rounded), n = 0; while (frac && frac < 1 && n < max) { frac *= 10; n++; } return dd.toFixed(n); } const transpose = (arr) => (arr && arr.length) ? arr[0].map((_, i) => arr.map(row => row[i])) : []; const esc = s => String(s) .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') .replace(/"/g,'&quot;').replace(/'/g,'&#39;'); function htmlTable(rows = [], headers = [], options = {}) { const { firstColHeader = true, header, description, style = 'text-align:left;border: 1px solid black; padding:5px;', fixed = 8, transposeValues=false } = options if(transposeValues) rows = transpose(rows) headers = headers.length === 0 ? '' : /*html*/`<thead> <tr>${headers.map(v => /*html*/`<th>${esc(v)}</th>`).join('')}</tr> </thead>` const body = /*html*/`<tbody> ${rows.map(row => `<tr> ${row.map((v, i) => firstColHeader && i === 0 ? /*html*/`<th>${round(v, fixed)}</th>` : /*html*/`<td>${round(v, fixed)}</td>`).join('')} </tr>`).join('')} </tbody>` return /*html*/`<div> ${header ? `<h3>${esc(header)}</h3>` : ''} ${description ? `<p>${esc(description)}</p>` : ''} <table style="${style}">${headers}${body}</table> </div>` } class TestBaseSimple { constructor(samples, testName, options = {}) { const { min = 2, sameSize = false } = options const entries = Object.entries(samples) if (entries.length < min) throw new Error(`${testName} requires at least 2 groups`); this.samples = entries.map(([name, values]) => stats(values, name, { min })) if(sameSize) { const nMin = Math.min(...this.samples.map(s => s.n)); this.samples.forEach(s => { if (s.n !== nMin) s.values = s.values.slice(0, nMin); }); } if (sameSize || min === 1) this.n = this.samples[0].n this.k = this.samples.length; this.testName = testName } } class TestBase extends TestBaseSimple { constructor(samples, testName, resultKeys, options = {}) { super(samples, testName, options) this.resultKeys = resultKeys } get descriptive() { return this.samples.map(({ name, mean, stdDev, n }) => ({ name, mean, stdDev, n })) } get htmlTable() { const { resultKeys, descriptive, testName, samples } = this const headers = resultKeys; const rows = [headers.map(k => this[k])]; const anovaTable = htmlTable(rows, headers, { firstColHeader: false, header: testName }); const dRows = descriptive.map(({ name, mean, stdDev, n }) => [name, mean, stdDev, n]); const descriptiveTable = htmlTable(dRows, ['name', 'mean', 'stdDev', 'n'], { header: 'Descriptive statistics' }); return `<div> <h2>${testName} between ${samples.map(({ name }) => name).join(' , ')}</h2> <hr> ${descriptiveTable} ${anovaTable} </div>`; } } class IndependentTTest extends TestBase { /** Independent-samples t-test (Student/Welch) + Levene’s test (как в SPSS) */ constructor(samples, welch = false) { super(samples, 'Independent T Test', [ 't','df','p','F', 'leveneF','leveneDf1','leveneDf2','leveneP', 'k']); this.#calcT(welch); this.#calcLevene(); } #calcT(welch) { const { mean: m1, n: n1, varianceSample: v1 } = this.samples[0]; const { mean: m2, n: n2, varianceSample: v2 } = this.samples[1]; if (welch) { const se2 = v1 / n1 + v2 / n2; const den = ((v1 / n1) ** 2) / (n1 - 1) + ((v2 / n2) ** 2) / (n2 - 1); this.t = (m1 - m2) / Math.sqrt(se2); this.df = (se2 * se2) / den; } else { this.df = n1 + n2 - 2; const sp2 = ((n1 - 1) * v1 + (n2 - 1) * v2) / this.df; this.t = (m1 - m2) / Math.sqrt(sp2 * (1 / n1 + 1 / n2)); } } #calcLevene() { const k = this.k; const N = this.samples.reduce((s, g) => s + g.n, 0); const zGroups = this.samples.map(({ values, mean }) => values.map(v => Math.abs(v - mean)) ); const n = zGroups.map(z => z.length); const zbar_i = zGroups.map((z, i) => z.reduce((a, b) => a + b, 0) / n[i]); const zbar = zbar_i.reduce((s, zi, i) => s + n[i] * zi, 0) / N; const ssb = zbar_i.reduce((s, zi, i) => s + n[i] * (zi - zbar) ** 2, 0); const ssw = zGroups.reduce((s, z, i) => s + z.reduce((acc, zij) => acc + (zij - zbar_i[i]) ** 2, 0), 0 ); const df1 = k - 1; const df2 = N - k; const msb = ssb / df1; const msw = ssw / df2; const F = msb / msw; this.leveneF = F; this.leveneDf1 = df1; this.leveneDf2 = df2; this.leveneP = 1 - CDF.f(F, df1, df2); } get p() { return 2 * (1 - CDF.t(Math.abs(this.t), this.df)); } get F() { return this.t * this.t; } } class OneWayAnova extends TestBase { constructor(samples, welch = false) { super(samples, 'One-way ANOVA', ['F', 'dfBetween', 'dfWithin', 'p', 'k', 'msw']) this.#calc(welch) } #calc(welch) { this.dfBetween = this.k - 1; if (welch) { const means = [], ns = [], vars = [], weights = []; this.samples.forEach(({ mean, n, varianceSample }) => { means.push(mean); ns.push(n); vars.push(varianceSample); weights.push(n / varianceSample); }); const W = weights.reduce((a, b) => a + b, 0); const gmW = means.reduce((s, mi, i) => s + weights[i] * mi, 0) / W; const ssbW = means.reduce((s, mi, i) => s + weights[i] * (mi - gmW) ** 2, 0); const msbW = ssbW / (this.k - 1); const a_i = weights.map(wi => 1 - wi / W); const sum_ai = ns.reduce((s, ni, i) => s + (a_i[i] * a_i[i]) / (ni - 1), 0); const c = 1 + (2 * (this.k - 2) / (this.k * this.k - 1)) * sum_ai; const df2 = (this.k * this.k - 1) / (3 * sum_ai); this.ssb = ssbW; this.msb = msbW; this.dfWithin = df2; this.ssw = c * df2; } else { const N = this.samples.reduce((t, { n }) => t + n, 0); this.dfWithin = N - this.k; this.grandMean = this.samples.reduce((t, { sum }) => t + sum, 0) / N this.ssb = this.samples.reduce((t, { mean, n }) => t + n * Math.pow(mean - this.grandMean, 2), 0); this.ssw = this.samples.reduce((t, { mean, values }) => t + values.reduce((acc, v) => acc + (v - mean) ** 2, 0), 0); this.msb = this.ssb / this.dfBetween } this.msw = this.ssw / this.dfWithin this.F = this.msb / this.msw } get p() { if (this._p === undefined) this._p = 1 - CDF.f(this.F, this.dfBetween, this.dfWithin); return this._p; } } class PairedTTest extends TestBase { constructor(samples) { super(samples, 'Paired T-Test', ['t', 'p', 'meanDelta', 'sdDelta', 'n', 'df'], { min: 2, sameSize: true }) this.#calc() } #calc() { this.diffs = this.samples[0].values.map((v, i) => v - this.samples[1].values[i]) this.meanDelta = this.diffs.reduce((t, v) => t + v, 0) / this.n let ss = 0; for (let i = 0; i < this.n; i++) ss += Math.pow(this.diffs[i] - this.meanDelta, 2); this.sdDelta = Math.sqrt(ss / (this.n - 1)); this.t = this.meanDelta / (this.sdDelta / Math.sqrt(this.n)); this.df = this.n - 1; } get p() { return 2 * (1 - CDF.t(Math.abs(this.t), this.df)) } } class OneSampleTTest extends TestBase { constructor(samples, mu0 = 0) { super(samples, 'One Sample T-Test', ['t', 'df', 'p'], { min: 1 }) if (this.n < 2) throw new Error('OneSampleTTest: need at least 2 observations'); this.mu0 = mu0; this.#calc(); } #calc() { const { n, mean, varianceSample } = this.samples[0] this.sd = Math.sqrt(varianceSample); this.se = this.sd / Math.sqrt(n); this.df = n - 1; this.t = this.se === 0 ? 0 : (mean - this.mu0) / this.se; } get p() { return 2 * (1 - CDF.t(Math.abs(this.t), this.df)); } } const prepare = (keys, data) => { if(keys.length === 0) return data const samples = {}; keys.forEach(k => samples[k] = data[k]) return samples } class CompareMeans { static IndependentTTest = IndependentTTest; static OneWayAnova = OneWayAnova; static PairedTTest = PairedTTest; static OneSampleTTest = OneSampleTTest; constructor(data) { this.data = data; } paired(...colNames) { return new PairedTTest(prepare(colNames, this.data)) } independent(...colNames) { return new IndependentTTest(prepare(colNames, this.data), false) } independentWelch(...colNames) { return new IndependentTTest(prepare(colNames, this.data), true) } anova(...colNames) { return new OneWayAnova(prepare(colNames, this.data), false) } anovaWelch(...colNames) { return new OneWayAnova(prepare(colNames, this.data), true) } oneSample(colName, mu0) { if(!colName) colName = Object.keys(this.data)[0] const data = { [colName]: this.data[colName] } return new OneSampleTTest(data, mu0) } } class Pearson extends TestBase { #sumCov; #covariance; #r; #p;#t; constructor(samples, population = false) { super(samples, `Pearson${population ? '' : ' Sample'} Test`, ['covariance','df','r','p','t'], { min: 2, sameSize: true }) this.df = this.n - 2; this.population = population } get sumCov() { if (this.#sumCov === undefined) { const { n, samples: [{ values: v1, mean: m1 }, { values: v2, mean: m2 }] } = this this.#sumCov = 0; for (let i = 0; i < n; i++) { this.#sumCov += (v1[i] - m1) * (v2[i] - m2) } } return this.#sumCov } get covariance() { if (this.#covariance === undefined) { const { sumCov, population, n } = this this.#covariance = population ? sumCov / n : n < 2 ? 0 : sumCov / (n - 1) } return this.#covariance } get r() { if (this.#r === undefined) { this.std1 = this.population ? this.samples[0].stdDev : this.samples[0].stdDevSample this.std2 = this.population ? this.samples[1].stdDev : this.samples[1].stdDevSample if (this.std1 === 0 || this.std2 === 0) this.#r = 0 else this.#r = this.covariance / (this.std1 * this.std2) this.#t = this.#r * Math.sqrt(this.df / Math.max(1 - this.#r * this.#r, 1e-16)); } return this.#r } get p() { if (this.df <= 1) return null if (this.#p === undefined) { this.#p = 2 * (1 - CDF.t(Math.abs(this.#t), this.df)); if (this.#p < 0) this.#p = 0; if (this.#p > 1) this.#p = 1; } return this.#p } get t() { return this.#t } } class CronbachAlpha extends TestBase { #ifItemsDeleted constructor(samples) { super(samples, 'Cronbach Alpha', ['alpha'], { sameSize: true, minK: 2 }); this.sumOfVariances = this.samples.reduce((t, { varianceSample }) => t + varianceSample, 0) const transposed = Array.from({ length: this.n }, (_, i) => Array.from({length:this.k},(_,j) => this.samples[j].values[i])) this.sumColumnVariance = stats(transposed.map(arr => arr.reduce((t,v) => t+v,0))).varianceSample this.bessel = this.k / (this.k - 1); this.alpha = this.sumColumnVariance === 0 ? 0 : this.bessel * (1 - this.sumOfVariances / this.sumColumnVariance); } get ifItemsDeleted() { if (this.#ifItemsDeleted) return this.#ifItemsDeleted this.#ifItemsDeleted = {}; this.samples.forEach(col => { const cols = {} this.samples.forEach((s) => { if (s.name !== col.name) cols[s.name] = s }) this.#ifItemsDeleted[col.name] = new CronbachAlpha(cols).alpha; }) return this.#ifItemsDeleted; } get htmlTable() { const { alpha, ifItemsDeleted } = this return ` <h2>Cronbach Alpha</h2> <hr> Alpha: ${alpha} <br><br> ${htmlTable(Object.entries(ifItemsDeleted),['variable','alpha if deleted'])} ` } } function rankAverage(arr) { /** Compute average ranks with tie handling. Returns { ranks, tieGroups } */ const n = arr.length; const idx = Array.from({ length: n }, (_, i) => i).filter(i => Number.isFinite(arr[i])); const pairs = idx.map(i => [arr[i], i]).sort((a, b) => (a[0] - b[0])); const ranks = new Array(n).fill(NaN); const tieGroups = []; // sizes of tied groups let i = 0; let r = 1; while (i < pairs.length) { let j = i + 1; while (j < pairs.length && pairs[j][0] === pairs[i][0]) j++; const size = j - i; const avgRank = (r + (r + size - 1)) / 2; for (let k = i; k < j; k++) { ranks[pairs[k][1]] = avgRank; } if (size > 1) tieGroups.push(size); r += size; i = j; } return { ranks, tieGroups }; } class Spearman extends TestBase { /** Spearman rank correlation (двухвыборочный) */ constructor(samples) { super(samples, 'Spearman', ['r', 't', 'df', 'p'], { sameSize: false, minK: 2 }); this.#calc() } #calc() { const s1 = this.samples[0], s2 = this.samples[1]; const n = Math.min(s1.n, s2.n); if (s1.n !== n) s1.values = s1.values.slice(0, n); if (s2.n !== n) s2.values = s2.values.slice(0, n); this.n = n; const x = s1.values, y = s2.values; const rx = rankAverage(x).ranks, ry = rankAverage(y).ranks; const mx = rx.reduce((a, b) => a + b, 0) / n, my = ry.reduce((a, b) => a + b, 0) / n; let num = 0, sx = 0, sy = 0; for (let i = 0; i < n; i++) { const dx = rx[i] - mx, dy = ry[i] - my; num += dx * dy; sx += dx * dx; sy += dy * dy; } if (sx === 0 || sy === 0) this.#zeroXY(); else this.#calcpr(sx,sy,num) } #zeroXY() { this.r = 0; this.df = this.n - 2; this.t = 0; this.p = 1; } #calcpr(sx,sy,num) { this.r = num / Math.sqrt(sx * sy); this.df = this.n - 2; if (Math.abs(this.r) >= 1) { this.t = this.r > 0 ? Infinity : -Infinity; this.p = 0; } else { this.t = this.r * Math.sqrt(this.df / (1 - this.r * this.r)); this.p = 2 * (1 - CDF.t(Math.abs(this.t), this.df)); } } } function tieGroupSizes(arr) { const map = new Map(); for (const v of arr) map.set(v, (map.get(v) || 0) + 1); const sizes = []; for (const s of map.values()) if (s > 1) sizes.push(s); return sizes; } function kendallCounts(X, Y) { const n = X.length; let C = 0, D = 0; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const dx = Math.sign(X[j] - X[i]); const dy = Math.sign(Y[j] - Y[i]); if (dx === 0 || dy === 0) continue; if (dx === dy) C++; else D++; } } const TxSizes = tieGroupSizes(X), TySizes = tieGroupSizes(Y); const n0 = n * (n - 1) / 2; const Tx = TxSizes.reduce((s, g) => s + g * (g - 1) / 2, 0); const Ty = TySizes.reduce((s, g) => s + g * (g - 1) / 2, 0); return { n, n0, C, D, Tx, Ty, TxSizes, TySizes }; } function tieSums(sizes) { let s1 = 0, s2 = 0, s3 = 0; for (const t of sizes) { s1 += t * (t - 1); s2 += t * (t - 1) * (2 * t + 5); s3 += t * (t - 1) * (t - 2); } return { s1, s2, s3 }; } class Kendall extends TestBase { constructor(samples, { twoSided = true } = {}) { super(samples, 'Kendall’s tau-b', ['tau','z','t','df','p'], { sameSize:true,min: 2 }); this.twoSided = twoSided this.samples.forEach(v => { if (v.n !== this.n) v.values.slice(0, this.n) }) if (this.n < 2) throw new Error('Spearman: need at least 2 observations'); this.#calc() } #calc() { const { n, n0, C, D, Tx, Ty, TxSizes, TySizes } = kendallCounts(this.samples[0].values, this.samples[1].values); const denomTau = Math.sqrt(Math.max((n0 - Tx) * (n0 - Ty), 1e-16)); const S = C - D; const tau = denomTau === 0 ? 0 : S / denomTau; const { s1: sx1, s2: sx2, s3: sx3 } = tieSums(TxSizes); const { s1: sy1, s2: sy2, s3: sy3 } = tieSums(TySizes); let varS = (n * (n - 1) * (2 * n + 5) - sx2 - sy2) / 18; if (n > 1) varS += (sx1 * sy1) / (2 * n * (n - 1)); if (n > 2) varS += (sx3 * sy3) / (9 * n * (n - 1) * (n - 2)); const cc = S === 0 ? 0 : (S > 0 ? 1 : -1); const z = varS > 0 ? (S - cc) / Math.sqrt(varS) : 0; this.tau = Math.max(-1, Math.min(1, tau)); this.z = z; this.n = n; this.S = S; this.Tx = Tx; this.Ty = Ty; this.n0 = n0; } get p() { let p = 2 * (1 - CDF.phi(Math.abs(this.z))); if (!this.twoSided) p = p / 2; if (p < 0) p = 0; if (p > 1) p = 1; return p } get t() { return this.z; } get df() { return Infinity; } } class Correlate { static CronbachAlpha = CronbachAlpha; static Pearson = Pearson; static Spearman = Spearman; static Kendall = Kendall; constructor(samples) { this.columns = samples this.keys = Object.keys(samples) if (this.keys.length < 2) throw new Error('At least 2 samples required') } #filterColumns(colFilter) { if (colFilter.length === 0) return this.columns const filtered = {} colFilter.forEach(k => { filtered[k] = this.columns[k] }); return filtered } cronbachAlpha(...colFilter) { return new CronbachAlpha(this.#filterColumns(colFilter)) } spearmanTwoSided(...colFilter) { return this.matrix(colFilter, (data) => new Spearman(data, true)) } spearman(...colFilter) { return this.matrix(colFilter, (data) => new Spearman(data, false)) } kendallTwoSided(...colFilter) { return this.matrix(colFilter, (data) => new Kendall(data, true)) } kendall(...colFilter) { return this.matrix(colFilter, (data) => new Kendall(data, false)) } pearson(...colFilter) { return this.matrix(colFilter, (data) => new Pearson(data, true)) } pearsonSample(...colFilter) { return this.matrix(colFilter, (data) => new Pearson(data, false)) } matrix(colFilter, fn) { const entries = Object.entries(this.#filterColumns(colFilter)) if (entries.length === 2) return fn({ [entries[0][0]]: entries[0][1], [entries[1][0]]: entries[1][1], }) const result = {} for (let i = 0; i < entries.length; i++) { const [name1, value1] = entries[i] for (let j = i + 1; j < entries.length; j++) { const [name2, values2] = entries[j] result[`${name1}|${name2}`] = fn({ [name1]: value1, [name2]: values2 }) } } return result } } function meanAbsDiff(x, y) { const n = Math.min(x.length, y.length); if (n === 0) return NaN; let s = 0; for (let i = 0; i < n; i++) s += Math.abs(x[i] - y[i]); return s / n; } function rangeUnionPlus(x, y) { let minv = Infinity, maxv = -Infinity; for (let i = 0; i < x.length; i++) { const v = x[i]; if (v < minv) minv = v; if (v > maxv) maxv = v; } for (let i = 0; i < y.length; i++) { const v = y[i]; if (v < minv) minv = v; if (v > maxv) maxv = v; } const r = maxv - minv; return (r === 0 || !Number.isFinite(r)) ? 1 : (r + 1); } function pearson(x, y) { const { r } = new Pearson({ x: x.values, y: y.values }, true) return Number.isFinite(r) ? Math.max(-1, Math.min(1, r)) : NaN } function computeDistances(samples, metric = 'mad') { const k = samples.length const distances = Array.from({ length: k }, () => Array(k).fill(0)); for (let i = 0; i < k; i++) { const colI = samples[i]; for (let j = i + 1; j < k; j++) { const colJ = samples[j]; let dist; if (metric === 'pearson') { const r = pearson(colI, colJ); if (!Number.isFinite(r)) throw new Error('Pearson correlation failed: non-finite'); dist = 1 - r; } else { const mad = meanAbsDiff(colI.values, colJ.values); const rng = rangeUnionPlus(colI.values, colJ.values); if (!Number.isFinite(mad) || !Number.isFinite(rng)) throw new Error('MAD distance failed: non-finite'); dist = mad / rng; } distances[i][j] = dist; distances[j][i] = dist; } distances[i][i] = 0; } return distances; } function buildClusters(obj) { const maxId = Math.max(...obj.labels); if (!Number.isFinite(maxId) || maxId < 1) return; for (let id = 1; id <= maxId; id++) { const clusterTable = {}; obj.samples.forEach(({ name, values }, idx) => { if (obj.labels[idx] === id) clusterTable[name] = [...values]; }); if (Object.keys(clusterTable).length > 0) obj.clusters.push(clusterTable); } } class Dbscan extends TestBase { constructor(samples, options = {}) { super(samples, 'Dbscan',[], { min: 2 }) const { eps = 0.4, minPts = 3, metric = 'mad' } = options this.metric = metric; this.eps = eps; this.minPts = minPts; this.labels = new Array(this.k).fill(0); this.clusters = []; this.distances = computeDistances(this.samples,this.metric) this.run(); buildClusters(this); } findNeighbors(pointIdx) { const neighbors = []; for (let i = 0; i < this.k; i++) { const d = this.distances[pointIdx][i]; if (Number.isFinite(d) && d <= 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) { for (const nb of currentNeighbors) { if (this.labels[nb] === 0) seeds.push(nb); } } } } run() { let clusterId = 0; for (let i = 0; i < this.k; 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); } } } class Hdbscan extends TestBase { constructor(samples, options = {}) { super(samples, 'Hdbscan', [], { min: 2 }) const { metric = 'mad', minClusterSize = 2 } = options this.metric = metric; this.minClusterSize = Math.max(2, minClusterSize); this.labels = new Array(this.k).fill(-1); this.clusters = []; this.mreachDistances = null; this.mst = []; this.hierarchy = []; this.distances = computeDistances(this.samples, this.metric); this.computeMutualReachability(); this.buildMST(); this.buildHierarchy(); this.extractClusters(); buildClusters(this); } computeMutualReachability() { this.mreachDistances = Array.from({ length: this.k }, () => Array(this.k).fill(0)); const k = Math.min(this.minClusterSize, this.k); const coreDistances = new Array(this.k); for (let i = 0; i < this.k; i++) { const sorted = this.distances[i].slice().sort((a, b) => a - b); coreDistances[i] = sorted[k - 1]; } for (let i = 0; i < this.k; i++) { for (let j = i; j < this.k; 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.k).fill(false); const key = new Array(this.k).fill(Infinity); const parent = new Array(this.k).fill(-1); key[0] = 0; this.mst = []; for (let count = 0; count < this.k; count++) { let minKey = Infinity; let u = -1; for (let v = 0; v < this.k; v++) { if (!visited[v] && key[v] < minKey) { minKey = key[v]; u = v; } } if (u === -1) break; visited[u] = true; if (parent[u] !== -1) this.mst.push([parent[u], u, this.mreachDistances[parent[u]][u]]); for (let v = 0; v < this.k; v++) { if (!visited[v] && this.mreachDistances[u][v] < key[v]) { key[v] = this.mreachDistances[u][v]; parent[v] = u; } } } if (this.mst.length > this.k - 1) this.mst.length = this.k - 1; this.mst.sort((a, b) => a[2] - b[2]); } buildHierarchy() { const uf = new UnionFind(this.k); const mapRootToNode = new Array(this.k); for (let i = 0; i < this.k; i++) { this.hierarchy.push({ clusterId: i, lambdaBirth: 0, lambdaDeath: null, points: [i], size: 1, children: [] }); mapRootToNode[i] = i; } for (const [p, q, weight] of this.mst) { const level = weight; const rp = uf.find(p); const rq = uf.find(q); if (rp === rq) continue; const nodeP = mapRootToNode[rp], nodeQ = mapRootToNode[rq]; if (this.hierarchy[nodeP].lambdaDeath == null) this.hierarchy[nodeP].lambdaDeath = level; if (this.hierarchy[nodeQ].lambdaDeath == null) this.hierarchy[nodeQ].lambdaDeath = level; const parentNodeIndex = this.hierarchy.length; const newPoints = this.hierarchy[nodeP].points.concat(this.hierarchy[nodeQ].points); this.hierarchy.push({ clusterId: parentNodeIndex, lambdaBirth: level, lambdaDeath: null, points: newPoints, size: newPoints.length, children: [nodeP, nodeQ] }); uf.union(rp, rq); const newRoot = uf.find(rp); mapRootToNode[newRoot] = parentNodeIndex; } for (const node of this.hierarchy) { if (node.lambdaDeath == null) node.lambdaDeath = node.lambdaBirth; } } extractClusters() { const EPS = 1e-12; const stability = new Map(); const eligible = []; for (const node of this.hierarchy) { if (node.size >= this.minClusterSize) { let s = (node.lambdaDeath - node.lambdaBirth) * node.size; if (!Number.isFinite(s) || s < EPS) s = 0; stability.set(node.clusterId, s); eligible.push(node); } } const positives = eligible.filter(n => (stability.get(n.clusterId) || 0) > 0); const active = new Map(); if (positives.length > 0) { for (const n of positives) { active.set(n.clusterId, { points: n.points, size: n.size, stab: stability.get(n.clusterId) }); } } else if (eligible.length > 0) { let full = eligible[0]; for (const n of eligible) if (n.size > full.size) full = n; active.set(full.clusterId, { points: full.points, size: full.size, stab: 0 }); } const order = Array.from(active.entries()) .sort(([, a], [, b]) => (b.stab - a.stab) || (b.size - a.size)) .map(([id]) => id); this.labels.fill(-1); let cid = 1; const assigned = new Set(); for (const k of order) { let any = false; for (const p of active.get(k).points) { if (!assigned.has(p)) { this.labels[p] = cid; assigned.add(p); any = true; } } if (any) cid++; } const unassigned = new Set(); for (let i = 0; i < this.k; i++) if (this.labels[i] === -1) unassigned.add(i); if (unassigned.size > 0) { const uf = new UnionFind(this.k); for (const [u, v] of this.mst) { uf.union(u, v); const comps = new Map(); for (const p of unassigned) { const r = uf.find(p); const arr = comps.get(r); if (arr) arr.push(p); else comps.set(r, [p]); } for (const members of comps.values()) { if (members.length >= this.minClusterSize) { for (const p of members) { this.labels[p] = cid; unassigned.delete(p); } cid++; } } if (unassigned.size === 0) break; } } } } class UnionFind { constructor(n) { this.parent = Array.from({ length: n }, (_, 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 rx = this.find(x), ry = this.find(y); if (rx === ry) return; if (this.rank[rx] < this.rank[ry]) this.parent[rx] = ry; else if (this.rank[rx] > this.rank[ry]) this.parent[ry] = rx; else { this.parent[ry] = rx; this.rank[rx]++; } } } class Clustering { static Dbscan = Dbscan static Hdbscan = Hdbscan static computeDistances = computeDistances } const MatrixUtils = { transpose (arr){ return (arr && arr.length) ? arr[0].map((_, i) => arr.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 EPS = 3e-14; 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++) { if (Math.abs(diag) < EPS) throw new Error('singular matrix'); 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 RegressionBase { constructor(columns, yName, xNames = [], step) { this.columns = columns; this.yName = yName; this.xNames = xNames this.step = step } calculate() { const { columns, yName, xNames } = this this.y = columns[yName]; this.X = columns[xNames[0]].map((_, i) => [1, ...xNames.map(name => columns[name][i])]); this.n = this.X.length; this.k = this.X[0].length; this.computeCoefficients(); this.coefficients.forEach(c => { if (!isFinite(c)) throw new Error(`Regression failed: singular matrix or constant predictors ${c}`); }) this.yHat = this.predict(this.X); } computeCoefficients() { } predict() { } get result() { if (!this.coefficients) this.calculate() return { step: this.step, n: this.n, Variable: ['Intercept', ...this.xNames], Coefficient: this.coefficients }; } get htmlTable() { const { result } = this, { step, n } = result; const headers = Object.keys(result).filter(k => k !== 'step' && k !== 'n'); const rows = headers.map((k) => result[k]) const header = `Step:${step} (${this.xNames.length} predictors, n=${n})` return htmlTable(rows, headers, { header, firstColHeader: true, transposeValues:true }) } } class LinearRegression extends RegressionBase { constructor(table, yName, xNames, step) { super(table, yName, xNames, step) } calculate() { super.calculate() this.residuals = this.y.map((yi, i) => yi - this.yHat[i]); this.r2 = this.computeR2(); this.standardErrors = this.computeStandardErrors(); this.pValues = this.computePValues(); if (this.n < this.k) throw new Error('Not enough observations for regression (n < predictors + 1)'); return this; } computeCoefficients() { const XT = MatrixUtils.transpose(this.X); const XTX = MatrixUtils.multiply(XT, this.X); const XTy = MatrixUtils.multiplyVec(XT, this.y); this.coefficients = 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) => { return 2 * (1 - CDF.t(Math.abs(this.coefficients[i] / se), this.n - this.k)); }); } get result() { return { ...super.result, StdError: this.standardErrors, pValue: this.pValues } } } const sigmoid = (z) => 1 / (1 + Math.exp(-z)) class LogisticRegression extends RegressionBase { tol = 1e-6; constructor(samples, yName, xNames, step, learningRate = 0.01, epochs = 1000) { super(samples, yName, xNames, step) this.learningRate = learningRate this.epochs = epochs } calculate() { super.calculate() const correct = this.yHat.reduce((acc, pred, i) => acc + (pred === this.y[i] ? 1 : 0), 0) this.accuracy = correct / this.n return this } computeCoefficients() { this.coefficients = Array(this.k).fill(0) for (let epoch = 0; epoch < this.epochs; epoch++) { const preds = this.predictProba(this.X) const errors = preds.map((p, i) => p - this.y[i]) const grads = Array(this.k).fill(0) for (let i = 0; i < this.n; i++) { for (let j = 0; j < this.k; j++) { grads[j] += this.X[i][j] * errors[i] } } for (let j = 0; j < this.k; j++) { this.coefficients[j] -= this.learningRate * grads[j] / this.n } const gradNorm = grads.reduce((sum, g) => sum + g * g, 0); if (gradNorm < this.tol * this.tol) break; } } predictProba(X) { return X.map(row => sigmoid(row.reduce((sum, val, j) => sum + val * this.coefficients[j], 0))) } predict(X, threshold = 0.5) { return this.predictProba(X).map(p => (p >= threshold ? 1 : 0)) } get result() { return { ...super.result, Accuracy: this.accuracy } } } class Regression extends TestBaseSimple { static LinearRegression = LinearRegression static LogisticRegression = LogisticRegression regressions = { linear: LinearRegression, logistic: LogisticRegression } constructor(data,options) { super(data, 'Multiple Regression', { min: 2 }) let { yName, xNames = [], type = 'linear' } = options this.Regression = this.regressions[type] this.yName = yName const colValues = {} this.samples.forEach(({ values, name }) => colValues[name] = values) if (xNames.length === 0) xNames = this.samples.map(({name}) => name).filter(n => n !== yName) this.steps = [new this.Regression(colValues, yName, xNames, 0).calculate()] } next(newPredictors = []) { let { columns, xNames, step, yName } = this.steps[this.steps.length - 1] const newTable = {}; for (let col in columns) newTable[col] = (columns[col] || columns[col].values).slice() newPredictors.forEach(xName => { if (xName.includes('*')) { const [colName1, colName2] = xName.split('*') newTable[xName] = columns[colName1].map((v, i) => v * newTable[colName2][i]) } }) const reg = new this.Regression(newTable, yName, [...new Set([...xNames, ...newPredictors])], step + 1) this.steps.push(reg.calculate()); return this } get results() { return this.steps.map(({ result }) => result) } get htmlTables() { return `<div> <h2>Linear regression to predict ${this.yName}</h2> <hr> ${this.steps.map(({ htmlTable }) => htmlTable).join('<hr>') } </div>` } } // clustering class Analyze { static CDF = CDF; static CompareMeans = CompareMeans; static Correlate = Correlate; static Clustering = Clustering; static Regression = Regression; } const range = (start, end, step = 1) => Array.from({ length: Math.floor((end - start) / step) }, (v, i) => start + i * step); function filterKeys(keys,filters) { if(filters.length === 0) return keys let include = new Set(), exclude = new Set(), regex = [],filtered = keys; filters.forEach(filter => { if (typeof filter === 'number') include.add(filter) else if (typeof filter === "string") { if (filter.startsWith("-")) exclude.add(filter.slice(1)); else include.add(filter); } else if (filter instanceof RegExp) regex.push(filter); }); if (include.size > 0) filtered = Array.from(include); else if (regex.length > 0) filtered = keys.filter(name => regex.some(pattern => pattern.test(name)) ); if (exclude.size > 0) filtered = filtered.filter(name => !exclude.has(name)); return filtered; } class Counter { constructor(name) { this._name = name; this.count = 0 } getName(name) { if(name) return name this.count++ return `${this._name}${this.count}` } } 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); } } } 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)); const low = mean - margin; const high = mean + margin; const width = mean !== 0 ? ((high - low) / Math.abs(mean)) * 100 : (high - low); 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, below = false) { const outliers = [], indexes = [], zs = []; for (let i = 0; i < zScores.length; i++) { const zi = zScores[i]; const z = twoFactors ? Math.abs(zi) : zi; const isOutlier = twoFactors ? (z > threshold) && (!below || zi < -threshold) : (below ? (zi < -threshold) : (zi > threshold)); if (!isOutlier) continue; outliers.push(values[i]); indexes.push(i); zs.push(zi); } 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; } function slopeByIndex(arr) { if (arr.length < 2) return 0; const firstIndex = 0; const lastIndex = arr.length - 1; const deltaY = arr[lastIndex] - arr[firstIndex]; const deltaX = lastIndex - firstIndex; return deltaY / deltaX; } function normalizeRules(rules) { return rules.map(r => { if (r.length === 2) return { kind: 'eq', left: r[0], right: r[1] }; else if (r.length === 3) return { kind: 'range', left: r[0], min:r[1], max:r[2] }; else throw new Error('Wrong rule format: ' + JSON.stringify(r)); }); } function inRange(x, min, max) { if (min != null && x < min) return false; if (max == null) return x >= min; return x >= min && x < max; } function frequencies({ values, labels }) { const freq = new Map(); if (!labels || labels.length === 0) { for (const v of values) freq.set(v, (freq.get(v) || 0) + 1); return Object.fromEntries(freq); } const rules = normalizeRules(labels); for (const v of values) { let key = null; for (const rule of rules) { if (rule.kind === 'eq' && v === rule.right) { key = rule.left; break; } if (rule.kind === 'range' && typeof v === 'number' && inRange(v, rule.min, rule.max)) { key = rule.left; break; } } if (key == null) key = '_other'; freq.set(key, (freq.get(key) || 0) + 1); } if (freq.has('_other') && freq.get('_other') === 0) freq.delete('_other'); return Object.fromEntries(freq); } function recode({ values }, ranges) { const rules = normalizeRules(ranges); return values.map(v => { for (const rule of rules) { if (rule.kind === 'eq') { if (v === rule.right) return rule.left; } else if (rule.kind === 'range' && typeof v === 'number') { if (inRange(v, rule.min, rule.max)) return rule.left; } } return v; }); } const nd = v => v === undefined class Stats { static sorted({ values }) { return values.slice().sort((a, b) => a - b) } static sum({ values }) { return values.reduce((acc, val) => acc + val, 0) } static mean({ values, sum }) { return ((sum ?? Stats.sum({ values })) / values.length) } static mode({ values }) { return mode(values) } static slope({ values }) { return slopeByIndex({ values }) } static confidenceInterval({ mean, stdDevSample, values }) { if (nd(mean)) mean = Stats.mean({ values }) if (nd(stdDevSample)) stdDevSample = Stats.stdDevSample({ values }) return confidenceInterval({ n: values.length, mean, stdDevSample }) } static weightedMean({ values }, weights) { return weightedMean(weights, values) } static outliersZScore({ values, zScores }, threshold, twoFactors, below) { if (nd(threshold)) threshold = 3 if (nd(twoFactors)) twoFactors = true if (nd(zScores)) zScores = Stats.zScores({ values }) return outliersZScore({ zScores, values }, threshold,