@johnhenry/math 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/Distributions.d.ts +86 -16
- package/dist/Distributions.d.ts.map +1 -1
- package/dist/Distributions.js +340 -89
- package/dist/Distributions.js.map +1 -1
- package/dist/EigenGeneral.d.ts +22 -0
- package/dist/EigenGeneral.d.ts.map +1 -0
- package/dist/EigenGeneral.js +327 -0
- package/dist/EigenGeneral.js.map +1 -0
- package/dist/Frobenius.d.ts +60 -0
- package/dist/Frobenius.d.ts.map +1 -0
- package/dist/Frobenius.js +162 -0
- package/dist/Frobenius.js.map +1 -0
- package/dist/Graph.d.ts +26 -0
- package/dist/Graph.d.ts.map +1 -1
- package/dist/Graph.js +75 -0
- package/dist/Graph.js.map +1 -1
- package/dist/MatrixMath.d.ts +10 -0
- package/dist/MatrixMath.d.ts.map +1 -1
- package/dist/MatrixMath.js +11 -0
- package/dist/MatrixMath.js.map +1 -1
- package/dist/MonteCarlo.d.ts +106 -0
- package/dist/MonteCarlo.d.ts.map +1 -0
- package/dist/MonteCarlo.js +183 -0
- package/dist/MonteCarlo.js.map +1 -0
- package/dist/Random.d.ts +31 -0
- package/dist/Random.d.ts.map +1 -0
- package/dist/Random.js +95 -0
- package/dist/Random.js.map +1 -0
- package/dist/Regression.d.ts +114 -0
- package/dist/Regression.d.ts.map +1 -0
- package/dist/Regression.js +338 -0
- package/dist/Regression.js.map +1 -0
- package/dist/Shader.d.ts +41 -0
- package/dist/Shader.d.ts.map +1 -0
- package/dist/Shader.js +758 -0
- package/dist/Shader.js.map +1 -0
- package/dist/SpecialFunctions.d.ts +3 -3
- package/dist/SpecialFunctions.d.ts.map +1 -1
- package/dist/SpecialFunctions.js +61 -51
- package/dist/SpecialFunctions.js.map +1 -1
- package/dist/Symbolic.d.ts +12 -0
- package/dist/Symbolic.d.ts.map +1 -1
- package/dist/Symbolic.js +16 -0
- package/dist/Symbolic.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Distributions.ts +391 -94
- package/src/EigenGeneral.ts +342 -0
- package/src/Frobenius.ts +203 -0
- package/src/Graph.ts +68 -0
- package/src/MatrixMath.ts +13 -0
- package/src/MonteCarlo.ts +257 -0
- package/src/Random.ts +104 -0
- package/src/Regression.ts +423 -0
- package/src/Shader.ts +837 -0
- package/src/SpecialFunctions.ts +52 -48
- package/src/Symbolic.ts +18 -0
- package/src/index.ts +34 -0
package/src/Distributions.ts
CHANGED
|
@@ -7,6 +7,10 @@ import { SpecialFunctions as SF } from "./SpecialFunctions.ts";
|
|
|
7
7
|
* gamma/beta functions from {@link SpecialFunctions} rather than numeric
|
|
8
8
|
* integration, so they are accurate in the tails.
|
|
9
9
|
*
|
|
10
|
+
* Names follow SciPy (`pdf`/`pmf`, `cdf`, `sf`, `ppf`, `rvs`) with the
|
|
11
|
+
* `scipy.stats` parameterisations noted per factory, except where noted
|
|
12
|
+
* (`exponential` takes a *rate*, `gamma` a *scale*).
|
|
13
|
+
*
|
|
10
14
|
* Every factory takes an optional trailing `rng: () => number` (default
|
|
11
15
|
* `Math.random`), threaded through to every nested distribution it samples
|
|
12
16
|
* from — pass a seeded generator to make `sample()` deterministic in tests.
|
|
@@ -15,17 +19,130 @@ import { SpecialFunctions as SF } from "./SpecialFunctions.ts";
|
|
|
15
19
|
export interface ContinuousDistribution {
|
|
16
20
|
pdf(x: number): number;
|
|
17
21
|
cdf(x: number): number;
|
|
22
|
+
/** Survival function `1 - cdf(x)`, accurate in the upper tail. */
|
|
23
|
+
sf(x: number): number;
|
|
24
|
+
/** Percent-point function (inverse CDF) for `p` in `[0, 1]`. */
|
|
25
|
+
ppf(p: number): number;
|
|
26
|
+
/** Alias of {@link ppf}. */
|
|
27
|
+
quantile(p: number): number;
|
|
18
28
|
mean(): number;
|
|
19
29
|
variance(): number;
|
|
30
|
+
std(): number;
|
|
20
31
|
sample(): number;
|
|
32
|
+
/** `n` independent draws (SciPy's `rvs(size=n)`). */
|
|
33
|
+
rvs(n: number): number[];
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
export interface DiscreteDistribution {
|
|
24
37
|
pmf(k: number): number;
|
|
25
38
|
cdf(k: number): number;
|
|
39
|
+
sf(k: number): number;
|
|
40
|
+
/** Smallest integer `k` with `cdf(k) >= p`. */
|
|
41
|
+
ppf(p: number): number;
|
|
42
|
+
quantile(p: number): number;
|
|
26
43
|
mean(): number;
|
|
27
44
|
variance(): number;
|
|
45
|
+
std(): number;
|
|
28
46
|
sample(): number;
|
|
47
|
+
rvs(n: number): number[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Rng = () => number;
|
|
51
|
+
|
|
52
|
+
/** Build a continuous distribution; `ppf` is derived by bisection unless supplied. */
|
|
53
|
+
function continuous(
|
|
54
|
+
d: Pick<ContinuousDistribution, "pdf" | "cdf" | "sf" | "mean" | "variance" | "sample"> & {
|
|
55
|
+
ppf?: (p: number) => number;
|
|
56
|
+
/** Support lower/upper bound (used to bracket the numerical inverse). */
|
|
57
|
+
lower?: number;
|
|
58
|
+
upper?: number;
|
|
59
|
+
},
|
|
60
|
+
): ContinuousDistribution {
|
|
61
|
+
const ppf =
|
|
62
|
+
d.ppf ??
|
|
63
|
+
((p: number) =>
|
|
64
|
+
invertContinuous(d.cdf, d.sf, p, d.lower ?? Number.NEGATIVE_INFINITY, d.upper ?? Number.POSITIVE_INFINITY));
|
|
65
|
+
return {
|
|
66
|
+
pdf: d.pdf,
|
|
67
|
+
cdf: d.cdf,
|
|
68
|
+
sf: d.sf,
|
|
69
|
+
ppf,
|
|
70
|
+
quantile: ppf,
|
|
71
|
+
mean: d.mean,
|
|
72
|
+
variance: d.variance,
|
|
73
|
+
std: () => Math.sqrt(d.variance()),
|
|
74
|
+
sample: d.sample,
|
|
75
|
+
rvs: (n) => Array.from({ length: n }, () => d.sample()),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function discrete(
|
|
80
|
+
d: Pick<DiscreteDistribution, "pmf" | "cdf" | "sf" | "mean" | "variance" | "sample"> & { max?: number },
|
|
81
|
+
): DiscreteDistribution {
|
|
82
|
+
const ppf = (p: number): number => {
|
|
83
|
+
if (Number.isNaN(p) || p < 0 || p > 1) return Number.NaN;
|
|
84
|
+
if (p === 0) return -1;
|
|
85
|
+
const max = d.max ?? Number.POSITIVE_INFINITY;
|
|
86
|
+
if (p === 1) return max;
|
|
87
|
+
let hi = Math.max(1, Math.ceil(d.mean() + 4 * Math.sqrt(d.variance())));
|
|
88
|
+
while (hi < max && d.cdf(hi) < p) hi *= 2;
|
|
89
|
+
hi = Math.min(hi, max);
|
|
90
|
+
let lo = -1; // cdf(-1) = 0 < p
|
|
91
|
+
while (hi - lo > 1) {
|
|
92
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
93
|
+
if (d.cdf(mid) >= p) hi = mid;
|
|
94
|
+
else lo = mid;
|
|
95
|
+
}
|
|
96
|
+
return hi;
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
pmf: d.pmf,
|
|
100
|
+
cdf: d.cdf,
|
|
101
|
+
sf: d.sf,
|
|
102
|
+
ppf,
|
|
103
|
+
quantile: ppf,
|
|
104
|
+
mean: d.mean,
|
|
105
|
+
variance: d.variance,
|
|
106
|
+
std: () => Math.sqrt(d.variance()),
|
|
107
|
+
sample: d.sample,
|
|
108
|
+
rvs: (n) => Array.from({ length: n }, () => d.sample()),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Invert a continuous CDF by bracketing + bisection. Uses `cdf` for the lower
|
|
114
|
+
* half and `sf` for the upper half so tail quantiles keep full precision.
|
|
115
|
+
*/
|
|
116
|
+
function invertContinuous(
|
|
117
|
+
cdf: (x: number) => number,
|
|
118
|
+
sf: (x: number) => number,
|
|
119
|
+
p: number,
|
|
120
|
+
lower: number,
|
|
121
|
+
upper: number,
|
|
122
|
+
): number {
|
|
123
|
+
if (Number.isNaN(p) || p < 0 || p > 1) return Number.NaN;
|
|
124
|
+
if (p === 0) return lower;
|
|
125
|
+
if (p === 1) return upper;
|
|
126
|
+
const below = p <= 0.5 ? (x: number) => cdf(x) < p : (x: number) => sf(x) > 1 - p;
|
|
127
|
+
let lo = lower;
|
|
128
|
+
if (!Number.isFinite(lo)) {
|
|
129
|
+
lo = -1;
|
|
130
|
+
for (let step = 1; !below(lo); step *= 2) lo -= step;
|
|
131
|
+
}
|
|
132
|
+
let hi = Number.isFinite(upper) ? upper : lo + 1;
|
|
133
|
+
if (!Number.isFinite(upper)) {
|
|
134
|
+
for (let step = 1; below(hi); step *= 2) {
|
|
135
|
+
lo = hi;
|
|
136
|
+
hi += step;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (let i = 0; i < 400; i++) {
|
|
140
|
+
const mid = lo + (hi - lo) / 2;
|
|
141
|
+
if (mid === lo || mid === hi) break;
|
|
142
|
+
if (below(mid)) lo = mid;
|
|
143
|
+
else hi = mid;
|
|
144
|
+
}
|
|
145
|
+
return hi;
|
|
29
146
|
}
|
|
30
147
|
|
|
31
148
|
/** The inverse standard-normal CDF (Acklam's rational approximation). */
|
|
@@ -68,88 +185,158 @@ function normInv(p: number): number {
|
|
|
68
185
|
);
|
|
69
186
|
}
|
|
70
187
|
|
|
188
|
+
const SQRT_2PI = Math.sqrt(2 * Math.PI);
|
|
189
|
+
|
|
71
190
|
export class Distributions {
|
|
72
|
-
/** The inverse standard-normal CDF (quantile / probit function). */
|
|
191
|
+
/** The inverse standard-normal CDF (quantile / probit function), to ~1e-15 after one Halley refinement. */
|
|
73
192
|
static normalQuantile(p: number, mean = 0, sd = 1): number {
|
|
74
|
-
|
|
193
|
+
if (Number.isNaN(p) || p < 0 || p > 1) return Number.NaN;
|
|
194
|
+
let x = normInv(p);
|
|
195
|
+
if (Number.isFinite(x)) {
|
|
196
|
+
const e = 0.5 * SF.erfc(-x / Math.SQRT2) - p;
|
|
197
|
+
const u = e * SQRT_2PI * Math.exp((x * x) / 2);
|
|
198
|
+
x -= u / (1 + (x * u) / 2);
|
|
199
|
+
}
|
|
200
|
+
return mean + sd * x;
|
|
75
201
|
}
|
|
76
202
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
203
|
+
/** Normal(`mean`, `sd`) -- `scipy.stats.norm(loc=mean, scale=sd)`. */
|
|
204
|
+
static normal(mean = 0, sd = 1, rng: Rng = Math.random): ContinuousDistribution {
|
|
205
|
+
return continuous({
|
|
206
|
+
pdf: (x) => Math.exp(-0.5 * ((x - mean) / sd) ** 2) / (sd * SQRT_2PI),
|
|
80
207
|
cdf: (x) => 0.5 * SF.erfc(-(x - mean) / (sd * Math.SQRT2)),
|
|
208
|
+
sf: (x) => 0.5 * SF.erfc((x - mean) / (sd * Math.SQRT2)),
|
|
209
|
+
ppf: (p) => Distributions.normalQuantile(p, mean, sd),
|
|
81
210
|
mean: () => mean,
|
|
82
211
|
variance: () => sd * sd,
|
|
83
212
|
sample: () => mean + sd * Math.sqrt(-2 * Math.log(1 - rng())) * Math.cos(2 * Math.PI * rng()),
|
|
84
|
-
};
|
|
213
|
+
});
|
|
85
214
|
}
|
|
86
215
|
|
|
87
|
-
|
|
88
|
-
|
|
216
|
+
/** Exponential with the given `rate` -- `scipy.stats.expon(scale=1/rate)`. */
|
|
217
|
+
static exponential(rate = 1, rng: Rng = Math.random): ContinuousDistribution {
|
|
218
|
+
return continuous({
|
|
89
219
|
pdf: (x) => (x < 0 ? 0 : rate * Math.exp(-rate * x)),
|
|
90
|
-
cdf: (x) => (x < 0 ? 0 :
|
|
220
|
+
cdf: (x) => (x < 0 ? 0 : -Math.expm1(-rate * x)),
|
|
221
|
+
sf: (x) => (x < 0 ? 1 : Math.exp(-rate * x)),
|
|
222
|
+
ppf: (p) => (p < 0 || p > 1 ? Number.NaN : -Math.log1p(-p) / rate),
|
|
91
223
|
mean: () => 1 / rate,
|
|
92
224
|
variance: () => 1 / (rate * rate),
|
|
93
225
|
sample: () => -Math.log(1 - rng()) / rate,
|
|
94
|
-
};
|
|
226
|
+
});
|
|
95
227
|
}
|
|
96
228
|
|
|
97
|
-
|
|
98
|
-
|
|
229
|
+
/** Uniform on `[a, b]` -- `scipy.stats.uniform(loc=a, scale=b-a)`. */
|
|
230
|
+
static uniform(a = 0, b = 1, rng: Rng = Math.random): ContinuousDistribution {
|
|
231
|
+
return continuous({
|
|
99
232
|
pdf: (x) => (x >= a && x <= b ? 1 / (b - a) : 0),
|
|
100
233
|
cdf: (x) => (x < a ? 0 : x > b ? 1 : (x - a) / (b - a)),
|
|
234
|
+
sf: (x) => (x < a ? 1 : x > b ? 0 : (b - x) / (b - a)),
|
|
235
|
+
ppf: (p) => (p < 0 || p > 1 ? Number.NaN : a + p * (b - a)),
|
|
101
236
|
mean: () => (a + b) / 2,
|
|
102
237
|
variance: () => (b - a) ** 2 / 12,
|
|
103
238
|
sample: () => a + rng() * (b - a),
|
|
104
|
-
};
|
|
239
|
+
});
|
|
105
240
|
}
|
|
106
241
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
242
|
+
/** Gamma with `shape` and `scale` -- `scipy.stats.gamma(a=shape, scale=scale)`. */
|
|
243
|
+
static gamma(shape: number, scale = 1, rng: Rng = Math.random): ContinuousDistribution {
|
|
244
|
+
return continuous({
|
|
245
|
+
pdf: (x) => {
|
|
246
|
+
if (x < 0) return 0;
|
|
247
|
+
if (x === 0) return shape < 1 ? Number.POSITIVE_INFINITY : shape === 1 ? 1 / scale : 0;
|
|
248
|
+
return Math.exp((shape - 1) * Math.log(x) - x / scale - SF.lnGamma(shape) - shape * Math.log(scale));
|
|
249
|
+
},
|
|
110
250
|
cdf: (x) => (x <= 0 ? 0 : SF.regularizedGammaP(shape, x / scale)),
|
|
251
|
+
sf: (x) => (x <= 0 ? 1 : SF.regularizedGammaQ(shape, x / scale)),
|
|
252
|
+
lower: 0,
|
|
111
253
|
mean: () => shape * scale,
|
|
112
254
|
variance: () => shape * scale * scale,
|
|
113
255
|
sample: () => Distributions.sampleGamma(shape, rng) * scale,
|
|
114
|
-
};
|
|
256
|
+
});
|
|
115
257
|
}
|
|
116
258
|
|
|
117
|
-
|
|
259
|
+
/** Chi-square with `df` degrees of freedom -- `scipy.stats.chi2(df)`. */
|
|
260
|
+
static chiSquare(df: number, rng: Rng = Math.random): ContinuousDistribution {
|
|
118
261
|
const g = Distributions.gamma(df / 2, 2, rng);
|
|
119
|
-
return { ...g, mean: () => df, variance: () => 2 * df };
|
|
262
|
+
return { ...g, mean: () => df, variance: () => 2 * df, std: () => Math.sqrt(2 * df) };
|
|
120
263
|
}
|
|
121
264
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
265
|
+
/** Student's t with `df` degrees of freedom -- `scipy.stats.t(df)`. */
|
|
266
|
+
static studentT(df: number, rng: Rng = Math.random): ContinuousDistribution {
|
|
267
|
+
const lc = SF.lnGamma((df + 1) / 2) - SF.lnGamma(df / 2) - 0.5 * Math.log(df * Math.PI);
|
|
268
|
+
const cdf = (x: number): number => {
|
|
269
|
+
if (x === Number.POSITIVE_INFINITY) return 1;
|
|
270
|
+
if (x === Number.NEGATIVE_INFINITY) return 0;
|
|
271
|
+
const ib = SF.regularizedIncompleteBeta(df / (df + x * x), df / 2, 0.5);
|
|
272
|
+
return x >= 0 ? 1 - 0.5 * ib : 0.5 * ib;
|
|
273
|
+
};
|
|
274
|
+
return continuous({
|
|
275
|
+
pdf: (x) => Math.exp(lc - ((df + 1) / 2) * Math.log1p((x * x) / df)),
|
|
276
|
+
cdf,
|
|
277
|
+
sf: (x) => cdf(-x),
|
|
130
278
|
mean: () => (df > 1 ? 0 : Number.NaN),
|
|
131
|
-
variance: () => (df > 2 ? df / (df - 2) : Number.POSITIVE_INFINITY),
|
|
279
|
+
variance: () => (df > 2 ? df / (df - 2) : df > 1 ? Number.POSITIVE_INFINITY : Number.NaN),
|
|
132
280
|
sample: () => {
|
|
133
281
|
const z = Distributions.normal(0, 1, rng).sample();
|
|
134
282
|
const chi = Distributions.chiSquare(df, rng).sample();
|
|
135
283
|
return z / Math.sqrt(chi / df);
|
|
136
284
|
},
|
|
137
|
-
};
|
|
285
|
+
});
|
|
138
286
|
}
|
|
139
287
|
|
|
140
|
-
|
|
288
|
+
/** Fisher-Snedecor F with numerator `d1` and denominator `d2` degrees of freedom -- `scipy.stats.f(d1, d2)`. */
|
|
289
|
+
static fisherF(d1: number, d2: number, rng: Rng = Math.random): ContinuousDistribution {
|
|
290
|
+
const lbeta = SF.lnGamma(d1 / 2) + SF.lnGamma(d2 / 2) - SF.lnGamma((d1 + d2) / 2);
|
|
291
|
+
return continuous({
|
|
292
|
+
pdf: (x) => {
|
|
293
|
+
if (x < 0) return 0;
|
|
294
|
+
if (x === 0) return d1 < 2 ? Number.POSITIVE_INFINITY : d1 === 2 ? 1 : 0;
|
|
295
|
+
return Math.exp(
|
|
296
|
+
0.5 * d1 * Math.log(d1) +
|
|
297
|
+
0.5 * d2 * Math.log(d2) +
|
|
298
|
+
(d1 / 2 - 1) * Math.log(x) -
|
|
299
|
+
((d1 + d2) / 2) * Math.log(d2 + d1 * x) -
|
|
300
|
+
lbeta,
|
|
301
|
+
);
|
|
302
|
+
},
|
|
303
|
+
cdf: (x) => (x <= 0 ? 0 : SF.regularizedIncompleteBeta((d1 * x) / (d1 * x + d2), d1 / 2, d2 / 2)),
|
|
304
|
+
sf: (x) => (x <= 0 ? 1 : SF.regularizedIncompleteBeta(d2 / (d2 + d1 * x), d2 / 2, d1 / 2)),
|
|
305
|
+
lower: 0,
|
|
306
|
+
mean: () => (d2 > 2 ? d2 / (d2 - 2) : Number.NaN),
|
|
307
|
+
variance: () =>
|
|
308
|
+
d2 > 4
|
|
309
|
+
? (2 * d2 * d2 * (d1 + d2 - 2)) / (d1 * (d2 - 2) ** 2 * (d2 - 4))
|
|
310
|
+
: d2 > 2
|
|
311
|
+
? Number.POSITIVE_INFINITY
|
|
312
|
+
: Number.NaN,
|
|
313
|
+
sample: () =>
|
|
314
|
+
(Distributions.sampleGamma(d1 / 2, rng) * 2) / d1 / ((Distributions.sampleGamma(d2 / 2, rng) * 2) / d2),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Binomial(`n`, `p`) -- `scipy.stats.binom(n, p)`. */
|
|
319
|
+
static binomial(n: number, p: number, rng: Rng = Math.random): DiscreteDistribution {
|
|
141
320
|
const logChoose = (k: number) => SF.lnGamma(n + 1) - SF.lnGamma(k + 1) - SF.lnGamma(n - k + 1);
|
|
142
|
-
const pmf = (k: number): number =>
|
|
143
|
-
k < 0 || k > n || !Number.isInteger(k)
|
|
144
|
-
|
|
321
|
+
const pmf = (k: number): number => {
|
|
322
|
+
if (k < 0 || k > n || !Number.isInteger(k)) return 0;
|
|
323
|
+
if (p === 0) return k === 0 ? 1 : 0;
|
|
324
|
+
if (p === 1) return k === n ? 1 : 0;
|
|
325
|
+
return Math.exp(logChoose(k) + k * Math.log(p) + (n - k) * Math.log1p(-p));
|
|
326
|
+
};
|
|
327
|
+
return discrete({
|
|
145
328
|
pmf,
|
|
146
329
|
cdf: (k) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
for (let i = 0; i <= Math.floor(k); i++) s += pmf(i);
|
|
151
|
-
return Math.min(1, s);
|
|
330
|
+
if (k < 0) return 0;
|
|
331
|
+
const f = Math.floor(k);
|
|
332
|
+
return f >= n ? 1 : SF.regularizedIncompleteBeta(1 - p, n - f, f + 1);
|
|
152
333
|
},
|
|
334
|
+
sf: (k) => {
|
|
335
|
+
if (k < 0) return 1;
|
|
336
|
+
const f = Math.floor(k);
|
|
337
|
+
return f >= n ? 0 : SF.regularizedIncompleteBeta(p, f + 1, n - f);
|
|
338
|
+
},
|
|
339
|
+
max: n,
|
|
153
340
|
mean: () => n * p,
|
|
154
341
|
variance: () => n * p * (1 - p),
|
|
155
342
|
sample: () => {
|
|
@@ -157,30 +344,41 @@ export class Distributions {
|
|
|
157
344
|
for (let i = 0; i < n; i++) if (rng() < p) count++;
|
|
158
345
|
return count;
|
|
159
346
|
},
|
|
160
|
-
};
|
|
347
|
+
});
|
|
161
348
|
}
|
|
162
349
|
|
|
163
|
-
|
|
164
|
-
|
|
350
|
+
/** Poisson(`lambda`) -- `scipy.stats.poisson(mu=lambda)`. */
|
|
351
|
+
static poisson(lambda: number, rng: Rng = Math.random): DiscreteDistribution {
|
|
352
|
+
return discrete({
|
|
165
353
|
pmf: (k) => (k < 0 || !Number.isInteger(k) ? 0 : Math.exp(k * Math.log(lambda) - lambda - SF.lnGamma(k + 1))),
|
|
166
354
|
cdf: (k) => (k < 0 ? 0 : SF.regularizedGammaQ(Math.floor(k) + 1, lambda)),
|
|
355
|
+
sf: (k) => (k < 0 ? 1 : SF.regularizedGammaP(Math.floor(k) + 1, lambda)),
|
|
167
356
|
mean: () => lambda,
|
|
168
357
|
variance: () => lambda,
|
|
169
358
|
sample: () => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
359
|
+
// Knuth's product method underflows exp(-lambda) past ~745, so split
|
|
360
|
+
// large rates into independent Poisson(<=500) pieces and add them.
|
|
361
|
+
let remaining = lambda;
|
|
362
|
+
let total = 0;
|
|
363
|
+
while (remaining > 0) {
|
|
364
|
+
const part = Math.min(remaining, 500);
|
|
365
|
+
remaining -= part;
|
|
366
|
+
const L = Math.exp(-part);
|
|
367
|
+
let k = 0;
|
|
368
|
+
let prod = 1;
|
|
369
|
+
do {
|
|
370
|
+
k++;
|
|
371
|
+
prod *= rng();
|
|
372
|
+
} while (prod > L);
|
|
373
|
+
total += k - 1;
|
|
374
|
+
}
|
|
375
|
+
return total;
|
|
178
376
|
},
|
|
179
|
-
};
|
|
377
|
+
});
|
|
180
378
|
}
|
|
181
379
|
|
|
182
380
|
/** Marsaglia-Tsang gamma sampler (shape ≥ 0). */
|
|
183
|
-
private static sampleGamma(shape: number, rng:
|
|
381
|
+
private static sampleGamma(shape: number, rng: Rng = Math.random): number {
|
|
184
382
|
if (shape < 1) return Distributions.sampleGamma(shape + 1, rng) * rng() ** (1 / shape);
|
|
185
383
|
const d = shape - 1 / 3;
|
|
186
384
|
const c = 1 / Math.sqrt(9 * d);
|
|
@@ -199,71 +397,170 @@ export class Distributions {
|
|
|
199
397
|
}
|
|
200
398
|
}
|
|
201
399
|
|
|
400
|
+
/** Result of a hypothesis test. `df` is `NaN` for tests without a single degrees-of-freedom value. */
|
|
202
401
|
export interface TestResult {
|
|
203
402
|
statistic: number;
|
|
204
403
|
df: number;
|
|
205
404
|
pValue: number;
|
|
206
405
|
}
|
|
207
406
|
|
|
407
|
+
export type Alternative = "two-sided" | "less" | "greater";
|
|
408
|
+
|
|
409
|
+
export interface TTestOptions {
|
|
410
|
+
/** Alternative hypothesis (SciPy's `alternative`). Default `"two-sided"`. */
|
|
411
|
+
alternative?: Alternative;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface TTestIndependentOptions extends TTestOptions {
|
|
415
|
+
/** Pooled-variance Student test when true (SciPy's `ttest_ind` default), Welch's when false. Default `true`. */
|
|
416
|
+
equalVar?: boolean;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export interface ChiSquareIndependenceResult extends TestResult {
|
|
420
|
+
/** Expected frequencies under independence. */
|
|
421
|
+
expected: number[][];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const sumOf = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0);
|
|
425
|
+
const meanOf = (xs: readonly number[]) => sumOf(xs) / xs.length;
|
|
426
|
+
const sampleVar = (xs: readonly number[]) => {
|
|
427
|
+
const m = meanOf(xs);
|
|
428
|
+
return xs.reduce((acc, x) => acc + (x - m) ** 2, 0) / (xs.length - 1);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/** p-value of a t statistic under the chosen alternative. */
|
|
432
|
+
function tPValue(t: number, df: number, alternative: Alternative = "two-sided"): number {
|
|
433
|
+
const dist = Distributions.studentT(df);
|
|
434
|
+
if (alternative === "less") return dist.cdf(t);
|
|
435
|
+
if (alternative === "greater") return dist.sf(t);
|
|
436
|
+
return Math.min(1, 2 * dist.sf(Math.abs(t)));
|
|
437
|
+
}
|
|
438
|
+
|
|
208
439
|
export class HypothesisTests {
|
|
209
|
-
/** One-sample
|
|
210
|
-
static tTestOneSample(sample: number[], mu0 = 0): TestResult {
|
|
440
|
+
/** One-sample Student t-test against a hypothesised mean `mu0` -- `scipy.stats.ttest_1samp`. */
|
|
441
|
+
static tTestOneSample(sample: number[], mu0 = 0, options: TTestOptions = {}): TestResult {
|
|
211
442
|
const n = sample.length;
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
const se = Math.sqrt(variance / n);
|
|
215
|
-
const t = (mean - mu0) / se;
|
|
443
|
+
const se = Math.sqrt(sampleVar(sample) / n);
|
|
444
|
+
const t = (meanOf(sample) - mu0) / se;
|
|
216
445
|
const df = n - 1;
|
|
217
|
-
|
|
218
|
-
|
|
446
|
+
return { statistic: t, df, pValue: tPValue(t, df, options.alternative) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Welch's two-sample t-test (unequal variances) -- `scipy.stats.ttest_ind(a, b, equal_var=False)`.
|
|
451
|
+
* This is the mallory behaviour; for SciPy's pooled default see {@link tTestIndependent}.
|
|
452
|
+
*/
|
|
453
|
+
static tTestTwoSample(a: number[], b: number[], options: TTestOptions = {}): TestResult {
|
|
454
|
+
return HypothesisTests.tTestIndependent(a, b, { ...options, equalVar: false });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Alias of Welch's test (`tTestTwoSample`). */
|
|
458
|
+
static welch(a: number[], b: number[], options: TTestOptions = {}): TestResult {
|
|
459
|
+
return HypothesisTests.tTestTwoSample(a, b, options);
|
|
219
460
|
}
|
|
220
461
|
|
|
221
|
-
/**
|
|
222
|
-
static
|
|
462
|
+
/** Independent two-sample t-test -- `scipy.stats.ttest_ind`. Pooled-variance (`equalVar: true`) by default, like SciPy. */
|
|
463
|
+
static tTestIndependent(a: number[], b: number[], options: TTestIndependentOptions = {}): TestResult {
|
|
464
|
+
const { equalVar = true, alternative } = options;
|
|
223
465
|
const na = a.length;
|
|
224
466
|
const nb = b.length;
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
467
|
+
const va = sampleVar(a);
|
|
468
|
+
const vb = sampleVar(b);
|
|
469
|
+
let t: number;
|
|
470
|
+
let df: number;
|
|
471
|
+
if (equalVar) {
|
|
472
|
+
df = na + nb - 2;
|
|
473
|
+
const pooled = ((na - 1) * va + (nb - 1) * vb) / df;
|
|
474
|
+
t = (meanOf(a) - meanOf(b)) / Math.sqrt(pooled * (1 / na + 1 / nb));
|
|
475
|
+
} else {
|
|
476
|
+
const sa = va / na;
|
|
477
|
+
const sb = vb / nb;
|
|
478
|
+
t = (meanOf(a) - meanOf(b)) / Math.sqrt(sa + sb);
|
|
479
|
+
df = (sa + sb) ** 2 / (sa ** 2 / (na - 1) + sb ** 2 / (nb - 1));
|
|
480
|
+
}
|
|
481
|
+
return { statistic: t, df, pValue: tPValue(t, df, alternative) };
|
|
234
482
|
}
|
|
235
483
|
|
|
236
|
-
/**
|
|
237
|
-
static
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
484
|
+
/** Paired t-test on matched samples -- `scipy.stats.ttest_rel`. */
|
|
485
|
+
static tTestPaired(a: number[], b: number[], options: TTestOptions = {}): TestResult {
|
|
486
|
+
if (a.length !== b.length) throw new Error("tTestPaired requires samples of equal length.");
|
|
487
|
+
return HypothesisTests.tTestOneSample(
|
|
488
|
+
a.map((x, i) => x - b[i]),
|
|
489
|
+
0,
|
|
490
|
+
options,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Pearson chi-square goodness-of-fit test -- `scipy.stats.chisquare`.
|
|
496
|
+
* `expected` defaults to uniform; `ddof` is SciPy's delta degrees of freedom
|
|
497
|
+
* (the p-value uses `k - 1 - ddof`). Throws when the totals disagree.
|
|
498
|
+
*/
|
|
499
|
+
static chiSquareGoodnessOfFit(observed: number[], expected?: number[], ddof = 0): TestResult {
|
|
500
|
+
const k = observed.length;
|
|
501
|
+
const exp = expected ?? new Array<number>(k).fill(sumOf(observed) / k);
|
|
502
|
+
if (exp.length !== k) throw new Error("observed and expected must have the same length.");
|
|
503
|
+
const so = sumOf(observed);
|
|
504
|
+
const se = sumOf(exp);
|
|
505
|
+
if (Math.abs(so - se) > 1e-8 * Math.max(Math.abs(so), Math.abs(se))) {
|
|
506
|
+
throw new Error(`observed (${so}) and expected (${se}) frequencies must sum to the same total.`);
|
|
507
|
+
}
|
|
508
|
+
const chi2 = observed.reduce((s, o, i) => s + (o - exp[i]) ** 2 / exp[i], 0);
|
|
509
|
+
const df = k - 1 - ddof;
|
|
510
|
+
return { statistic: chi2, df, pValue: Distributions.chiSquare(df).sf(chi2) };
|
|
242
511
|
}
|
|
243
512
|
|
|
244
|
-
/**
|
|
513
|
+
/**
|
|
514
|
+
* Chi-square test of independence on a contingency table --
|
|
515
|
+
* `scipy.stats.chi2_contingency`. Like SciPy, applies Yates' continuity
|
|
516
|
+
* correction by default when there is one degree of freedom.
|
|
517
|
+
*/
|
|
518
|
+
static chiSquareIndependence(table: number[][], options: { correction?: boolean } = {}): ChiSquareIndependenceResult {
|
|
519
|
+
const { correction = true } = options;
|
|
520
|
+
const r = table.length;
|
|
521
|
+
const c = table[0].length;
|
|
522
|
+
const rows = table.map(sumOf);
|
|
523
|
+
const cols = Array.from({ length: c }, (_, j) => sumOf(table.map((row) => row[j])));
|
|
524
|
+
const total = sumOf(rows);
|
|
525
|
+
const expected = table.map((_, i) => cols.map((cj) => (rows[i] * cj) / total));
|
|
526
|
+
const df = (r - 1) * (c - 1);
|
|
527
|
+
let stat = 0;
|
|
528
|
+
for (let i = 0; i < r; i++) {
|
|
529
|
+
for (let j = 0; j < c; j++) {
|
|
530
|
+
let diff = Math.abs(table[i][j] - expected[i][j]);
|
|
531
|
+
if (correction && df === 1) diff = Math.max(0, diff - 0.5);
|
|
532
|
+
stat += diff ** 2 / expected[i][j];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return { statistic: stat, df, pValue: df === 0 ? 1 : Distributions.chiSquare(df).sf(stat), expected };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** t-based confidence interval for the mean of a sample. */
|
|
245
539
|
static confidenceIntervalMean(sample: number[], level = 0.95): [number, number] {
|
|
246
540
|
const n = sample.length;
|
|
247
|
-
const m = sample
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
const alpha = 1 - level;
|
|
251
|
-
const tCrit = tQuantile(1 - alpha / 2, n - 1);
|
|
541
|
+
const m = meanOf(sample);
|
|
542
|
+
const se = Math.sqrt(sampleVar(sample) / n);
|
|
543
|
+
const tCrit = Distributions.studentT(n - 1).ppf(1 - (1 - level) / 2);
|
|
252
544
|
return [m - tCrit * se, m + tCrit * se];
|
|
253
545
|
}
|
|
254
|
-
}
|
|
255
546
|
|
|
256
|
-
|
|
547
|
+
/** Welch confidence interval for `mean(a) - mean(b)`. */
|
|
548
|
+
static confidenceIntervalDifference(a: number[], b: number[], level = 0.95): [number, number] {
|
|
549
|
+
const sa = sampleVar(a) / a.length;
|
|
550
|
+
const sb = sampleVar(b) / b.length;
|
|
551
|
+
const df = (sa + sb) ** 2 / (sa ** 2 / (a.length - 1) + sb ** 2 / (b.length - 1));
|
|
552
|
+
const d = meanOf(a) - meanOf(b);
|
|
553
|
+
const half = Distributions.studentT(df).ppf(1 - (1 - level) / 2) * Math.sqrt(sa + sb);
|
|
554
|
+
return [d - half, d + half];
|
|
555
|
+
}
|
|
257
556
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
else hi = mid;
|
|
557
|
+
/** Wilson score interval for a binomial proportion (`scipy.stats.binomtest(...).proportion_ci(method="wilson", ...)` without continuity correction). */
|
|
558
|
+
static confidenceIntervalProportion(successes: number, n: number, level = 0.95): [number, number] {
|
|
559
|
+
const z = Distributions.normalQuantile(1 - (1 - level) / 2);
|
|
560
|
+
const phat = successes / n;
|
|
561
|
+
const denom = 1 + (z * z) / n;
|
|
562
|
+
const centre = (phat + (z * z) / (2 * n)) / denom;
|
|
563
|
+
const half = (z * Math.sqrt((phat * (1 - phat)) / n + (z * z) / (4 * n * n))) / denom;
|
|
564
|
+
return [Math.max(0, centre - half), Math.min(1, centre + half)];
|
|
267
565
|
}
|
|
268
|
-
return (lo + hi) / 2;
|
|
269
566
|
}
|