@johnhenry/math 0.2.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.
Files changed (52) hide show
  1. package/README.md +4 -3
  2. package/dist/Distributions.d.ts +86 -16
  3. package/dist/Distributions.d.ts.map +1 -1
  4. package/dist/Distributions.js +340 -89
  5. package/dist/Distributions.js.map +1 -1
  6. package/dist/EigenGeneral.d.ts +22 -0
  7. package/dist/EigenGeneral.d.ts.map +1 -0
  8. package/dist/EigenGeneral.js +327 -0
  9. package/dist/EigenGeneral.js.map +1 -0
  10. package/dist/Frobenius.d.ts +60 -0
  11. package/dist/Frobenius.d.ts.map +1 -0
  12. package/dist/Frobenius.js +162 -0
  13. package/dist/Frobenius.js.map +1 -0
  14. package/dist/Graph.d.ts +26 -0
  15. package/dist/Graph.d.ts.map +1 -1
  16. package/dist/Graph.js +75 -0
  17. package/dist/Graph.js.map +1 -1
  18. package/dist/MatrixMath.d.ts +10 -0
  19. package/dist/MatrixMath.d.ts.map +1 -1
  20. package/dist/MatrixMath.js +11 -0
  21. package/dist/MatrixMath.js.map +1 -1
  22. package/dist/MonteCarlo.d.ts +106 -0
  23. package/dist/MonteCarlo.d.ts.map +1 -0
  24. package/dist/MonteCarlo.js +183 -0
  25. package/dist/MonteCarlo.js.map +1 -0
  26. package/dist/Random.d.ts +31 -0
  27. package/dist/Random.d.ts.map +1 -0
  28. package/dist/Random.js +95 -0
  29. package/dist/Random.js.map +1 -0
  30. package/dist/Regression.d.ts +114 -0
  31. package/dist/Regression.d.ts.map +1 -0
  32. package/dist/Regression.js +338 -0
  33. package/dist/Regression.js.map +1 -0
  34. package/dist/SpecialFunctions.d.ts +3 -3
  35. package/dist/SpecialFunctions.d.ts.map +1 -1
  36. package/dist/SpecialFunctions.js +61 -51
  37. package/dist/SpecialFunctions.js.map +1 -1
  38. package/dist/index.d.ts +6 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Distributions.ts +391 -94
  44. package/src/EigenGeneral.ts +342 -0
  45. package/src/Frobenius.ts +203 -0
  46. package/src/Graph.ts +68 -0
  47. package/src/MatrixMath.ts +13 -0
  48. package/src/MonteCarlo.ts +257 -0
  49. package/src/Random.ts +104 -0
  50. package/src/Regression.ts +423 -0
  51. package/src/SpecialFunctions.ts +52 -48
  52. package/src/index.ts +33 -0
@@ -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
- return mean + sd * normInv(p);
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
- static normal(mean = 0, sd = 1, rng: () => number = Math.random): ContinuousDistribution {
78
- return {
79
- pdf: (x) => Math.exp(-0.5 * ((x - mean) / sd) ** 2) / (sd * Math.sqrt(2 * Math.PI)),
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
- static exponential(rate = 1, rng: () => number = Math.random): ContinuousDistribution {
88
- return {
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 : 1 - Math.exp(-rate * x)),
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
- static uniform(a = 0, b = 1, rng: () => number = Math.random): ContinuousDistribution {
98
- return {
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
- static gamma(shape: number, scale = 1, rng: () => number = Math.random): ContinuousDistribution {
108
- return {
109
- pdf: (x) => (x <= 0 ? 0 : (x ** (shape - 1) * Math.exp(-x / scale)) / (SF.gamma(shape) * scale ** shape)),
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
- static chiSquare(df: number, rng: () => number = Math.random): ContinuousDistribution {
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
- static studentT(df: number, rng: () => number = Math.random): ContinuousDistribution {
123
- const c = SF.gamma((df + 1) / 2) / (Math.sqrt(df * Math.PI) * SF.gamma(df / 2));
124
- return {
125
- pdf: (x) => c * (1 + (x * x) / df) ** (-(df + 1) / 2),
126
- cdf: (x) => {
127
- const ib = SF.regularizedIncompleteBeta(df / (df + x * x), df / 2, 0.5);
128
- return x >= 0 ? 1 - 0.5 * ib : 0.5 * ib;
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
- static binomial(n: number, p: number, rng: () => number = Math.random): DiscreteDistribution {
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) ? 0 : Math.exp(logChoose(k) + k * Math.log(p) + (n - k) * Math.log(1 - p));
144
- return {
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
- // Reuse the single pmf closure above instead of rebuilding a whole new
148
- // binomial distribution (and its logChoose closure) on every step.
149
- let s = 0;
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
- static poisson(lambda: number, rng: () => number = Math.random): DiscreteDistribution {
164
- return {
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
- const L = Math.exp(-lambda);
171
- let k = 0;
172
- let prod = 1;
173
- do {
174
- k++;
175
- prod *= rng();
176
- } while (prod > L);
177
- return k - 1;
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: () => number = Math.random): number {
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 two-tailed Student t-test against a hypothesised mean `mu0`. */
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 mean = sample.reduce((a, b) => a + b, 0) / n;
213
- const variance = sample.reduce((acc, x) => acc + (x - mean) ** 2, 0) / (n - 1);
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
- const p = 2 * (1 - Distributions.studentT(df).cdf(Math.abs(t)));
218
- return { statistic: t, df, pValue: p };
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
- /** Welch's two-sample t-test (unequal variances). */
222
- static tTestTwoSample(a: number[], b: number[]): TestResult {
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 ma = a.reduce((s, x) => s + x, 0) / na;
226
- const mb = b.reduce((s, x) => s + x, 0) / nb;
227
- const va = a.reduce((s, x) => s + (x - ma) ** 2, 0) / (na - 1);
228
- const vb = b.reduce((s, x) => s + (x - mb) ** 2, 0) / (nb - 1);
229
- const se = Math.sqrt(va / na + vb / nb);
230
- const t = (ma - mb) / se;
231
- const df = (va / na + vb / nb) ** 2 / ((va / na) ** 2 / (na - 1) + (vb / nb) ** 2 / (nb - 1));
232
- const p = 2 * (1 - Distributions.studentT(df).cdf(Math.abs(t)));
233
- return { statistic: t, df, pValue: p };
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
- /** Pearson chi-square goodness-of-fit test. */
237
- static chiSquareGoodnessOfFit(observed: number[], expected: number[]): TestResult {
238
- const chi2 = observed.reduce((s, o, i) => s + (o - expected[i]) ** 2 / expected[i], 0);
239
- const df = observed.length - 1;
240
- const p = 1 - Distributions.chiSquare(df).cdf(chi2);
241
- return { statistic: chi2, df, pValue: p };
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
- /** Confidence interval for the mean of a sample at the given level (t-based). */
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.reduce((a, b) => a + b, 0) / n;
248
- const variance = sample.reduce((acc, x) => acc + (x - m) ** 2, 0) / (n - 1);
249
- const se = Math.sqrt(variance / n);
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
- // -- small internal helpers -------------------------------------------------
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
- /** Quantile of Student-t via bisection on its CDF. */
259
- function tQuantile(p: number, df: number): number {
260
- const dist = Distributions.studentT(df);
261
- let lo = -100;
262
- let hi = 100;
263
- for (let i = 0; i < 200; i++) {
264
- const mid = (lo + hi) / 2;
265
- if (dist.cdf(mid) < p) lo = mid;
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
  }